Skip to content

Latest commit

 

History

History
353 lines (280 loc) · 14.2 KB

server-requests.md

File metadata and controls

353 lines (280 loc) · 14.2 KB

Server-initiated requests

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

On revision 2026-07-28, a server can't send requests to the client. It returns elicitation, sampling and roots requests in an input_required result instead, and the client answers them with the same request handlers. A default client uses this revision when the server speaks it.

A server that sends sampling or roots requests through its ClientGateway fails on this revision. To receive those requests, call setProtocolVersion(ProtocolVersion::V2025_11_25) on the client builder.

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\Client;
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
use Mcp\Client\Handler\Request\ElicitationRequestHandler;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Elicitation\AbstractSchemaDefinition;
use Mcp\Schema\Elicitation\BooleanSchemaDefinition;
use Mcp\Schema\Elicitation\EnumSchemaDefinition;
use Mcp\Schema\Elicitation\NumberSchemaDefinition;
use Mcp\Schema\Enum\ElicitAction;
use Mcp\Schema\Enum\ElicitationMode;
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;

        // In url mode there is no schema to fill in — the user completes the
        // interaction in the browser instead.
        if (ElicitationMode::Url === $request->mode) {
            echo 'Continue in your browser: '.$request->url.\PHP_EOL;

            return new ElicitResult(ElicitAction::Accept);
        }

        // Form mode: start from the defaults the server declared, then collect input.
        $content = $request->requestedSchema->getDefaults();
        foreach ($request->requestedSchema->properties as $name => $definition) {
            while (true) {
                $answer = readline(($definition->title ?? $name).': ');

                if (false === $answer) {
                    // no input available, let the server know the user canceled
                    return new ElicitResult(ElicitAction::Cancel);
                }

                if ('' === $answer) {
                    // keep the default, if any
                    break;
                }

                $value = $this->parse($definition, $answer);
                if (null !== $value) {
                    $content[$name] = $value;
                    break;
                }

                echo 'Invalid value, please try again.'.\PHP_EOL;
            }
        }

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

    // readline() returns strings, null means the answer doesn't fit the field
    private function parse(AbstractSchemaDefinition $definition, string $answer): string|int|float|bool|null
    {
        if ($definition instanceof BooleanSchemaDefinition) {
            return filter_var($answer, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);
        }

        if ($definition instanceof NumberSchemaDefinition) {
            $number = filter_var($answer, $definition->integerOnly ? \FILTER_VALIDATE_INT : \FILTER_VALIDATE_FLOAT, \FILTER_NULL_ON_FAILURE);

            // the bounds may be fractional, even for an integer field
            if (null === $number
                || (null !== $definition->minimum && $number < $definition->minimum)
                || (null !== $definition->maximum && $number > $definition->maximum)) {
                return null;
            }

            return $number;
        }

        if ($definition instanceof EnumSchemaDefinition) {
            return \in_array($answer, $definition->enum, true) ? $answer : null;
        }

        return $answer;
    }
}

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

The handler doesn't fill in default values. getDefaults() returns the default of each field that declares one, keyed by field name.

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.

!!! warning Error Handling in Elicitation Callbacks:

- **Throw `ElicitationException`** to forward a specific error message to the server
- **Any other exception** returns a generic error to the server. The handler logs it when you
  pass a PSR-3 logger as its second argument.

```php
// 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');
```

This holds on `2025-11-25`. On `2026-07-28`, the error is not sent to the server. Instead,
the call that asked for the input (e.g. `callTool()`) throws a `RuntimeException` with the
message.

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

Sampling (LLM Requests)

Deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead.

Handle server requests for LLM completions:

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

class LlmSamplingCallback implements SamplingCallbackInterface
{
    // AcmeLlmClient stands for the client of your LLM provider
    public function __construct(private readonly AcmeLlmClient $llmClient)
    {
    }

    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($llmClient)))
    ->build();

Sampling with Tools

Clients that support tool-enabled sampling should advertise that capability and forward the request's tools and toolChoice fields to their LLM provider. A provider response that requests tools can be returned as one or more ToolUseContent blocks:

use Mcp\Client\Client;
use Mcp\Client\Handler\Request\SamplingRequestHandler;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\ToolUseContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Result\CreateSamplingMessageResult;

$client = Client::builder()
    ->setCapabilities(new ClientCapabilities(
        sampling: true,
        samplingContext: true,
        samplingTools: true,
    ))
    ->addRequestHandler(new SamplingRequestHandler($samplingCallback))
    ->build();

// Inside the sampling callback, after invoking the LLM provider:
return new CreateSamplingMessageResult(
    role: Role::Assistant,
    content: array_map(
        static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input),
        $providerResponse->toolCalls,
    ),
    model: $providerResponse->model,
    stopReason: 'toolUse',
);

The server executes the requested tools and sends their results in a later sampling request as ToolResultContent blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop.

!!! warning 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** returns a generic error to the server. The handler logs it when you
  pass a PSR-3 logger as its second argument.

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

```php
// 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');
```

This holds on `2025-11-25`. On `2026-07-28`, the error is not sent to the server. Instead,
the call that asked for the input (e.g. `callTool()`) throws a `RuntimeException` with the
message.

Logging Notifications

Deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead.

Receive structured log messages from the server:

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

// $logger is your application's PSR-3 logger
$loggingHandler = new LoggingNotificationHandler(
    static function (LoggingMessageNotification $notification) use ($logger) {
        $message = \is_string($notification->data) ? $notification->data : json_encode($notification->data);

        // MCP log levels use the same names as the PSR-3 log levels
        $logger->log($notification->level->value, $message);
    }
);

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

$client->connect($transport);

// call this after connect(), on 2026-07-28 the server sends no log messages without it
$client->setLoggingLevel(LoggingLevel::Info);

Before connect(), setLoggingLevel() throws a ConnectionException. On revision 2026-07-28, the client sends the level with every following request, and the server sends log messages only for a request that carries a level. See Protocol versions.

Roots

Deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead.

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\Client;
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();

Throw a RootsException in the callback to forward a specific error message to the server. Any other exception returns a generic error, like in the elicitation and sampling callbacks. This holds on 2025-11-25. On 2026-07-28, the error is not sent to the server. Instead, the call that asked for the roots (e.g. callTool()) throws a RuntimeException with the message.

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();

On revision 2026-07-28, sendRootsListChanged() sends nothing. The server asks for the roots with each call that needs them.

See examples/client/stdio_roots.php for a runnable example. It calls the inspect_workspace_roots tool of the client-communication demo server. On revision 2026-07-28, the tool returns an input_required result asking for the roots. The client answers it with the roots handler and sends the call again.

On 2025-11-25, the server sends a roots/list request to the client instead. Both ways call the same handler.