[Client] Add client-side elicitation support (#371)
* [Client] Add client-side elicitation support The SDK could send elicitation/create requests from a server tool via ClientGateway::elicit(), but the client could not receive them: there was no request handler, and ElicitRequest was not registered in MessageFactory, so incoming elicitation/create messages failed to parse. This adds the client half, mirroring the existing Sampling pattern: - Register ElicitRequest in MessageFactory so elicitation/create parses. - ElicitationRequestHandler wraps a user callback and returns an ElicitResult (accept/decline/cancel) or an Error. - ElicitationCallbackInterface defines the callback contract. - ElicitationException forwards a specific message to the server; any other throwable returns a generic error. Includes unit tests, a runnable client example against the elicitation demo server, and client docs/README updates. * [Client] Simplify elicitation example labelFor() per review Type the parameter as AbstractSchemaDefinition and read ->title directly instead of matching on each concrete definition type. title is a required non-null string on the base class, so the match and the $name fallback were redundant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
206aa9da52
commit
e4a8a224ff
1 changed files with 495 additions and 1 deletions
|
|
@ -0,0 +1,138 @@
|
|||
<?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\Tests\Unit\Client\Handler\Request;
|
||||
|
||||
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
|
||||
use Mcp\Client\Handler\Request\ElicitationRequestHandler;
|
||||
use Mcp\Exception\ElicitationException;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Request\PingRequest;
|
||||
use Mcp\Schema\Result\ElicitResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ElicitationRequestHandlerTest extends TestCase
|
||||
{
|
||||
public function testSupportsElicitRequest(): void
|
||||
{
|
||||
$handler = new ElicitationRequestHandler($this->callbackReturning(
|
||||
new ElicitResult(ElicitAction::Decline),
|
||||
));
|
||||
|
||||
$this->assertTrue($handler->supports($this->createElicitRequest()));
|
||||
}
|
||||
|
||||
public function testDoesNotSupportOtherRequests(): void
|
||||
{
|
||||
$handler = new ElicitationRequestHandler($this->callbackReturning(
|
||||
new ElicitResult(ElicitAction::Decline),
|
||||
));
|
||||
|
||||
$ping = PingRequest::fromArray([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => PingRequest::getMethod(),
|
||||
'id' => 'ping-1',
|
||||
]);
|
||||
|
||||
$this->assertFalse($handler->supports($ping));
|
||||
}
|
||||
|
||||
public function testHandleReturnsResponseOnAccept(): void
|
||||
{
|
||||
$result = new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']);
|
||||
$handler = new ElicitationRequestHandler($this->callbackReturning($result));
|
||||
|
||||
$request = $this->createElicitRequest();
|
||||
$response = $handler->handle($request);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $response);
|
||||
$this->assertSame($request->getId(), $response->id);
|
||||
$this->assertSame($result, $response->result);
|
||||
}
|
||||
|
||||
public function testHandleReturnsErrorOnElicitationException(): void
|
||||
{
|
||||
$handler = new ElicitationRequestHandler($this->callbackThrowing(
|
||||
new ElicitationException('user input unavailable'),
|
||||
));
|
||||
|
||||
$request = $this->createElicitRequest();
|
||||
$response = $handler->handle($request);
|
||||
|
||||
$this->assertInstanceOf(Error::class, $response);
|
||||
$this->assertSame($request->getId(), $response->getId());
|
||||
$this->assertSame('user input unavailable', $response->message);
|
||||
}
|
||||
|
||||
public function testHandleReturnsGenericErrorOnThrowable(): void
|
||||
{
|
||||
$handler = new ElicitationRequestHandler($this->callbackThrowing(
|
||||
new \RuntimeException('boom'),
|
||||
));
|
||||
|
||||
$request = $this->createElicitRequest();
|
||||
$response = $handler->handle($request);
|
||||
|
||||
$this->assertInstanceOf(Error::class, $response);
|
||||
$this->assertSame($request->getId(), $response->getId());
|
||||
$this->assertSame('Error while processing elicitation', $response->message);
|
||||
}
|
||||
|
||||
private function createElicitRequest(): ElicitRequest
|
||||
{
|
||||
return ElicitRequest::fromArray([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => ElicitRequest::getMethod(),
|
||||
'id' => 'elicit-'.uniqid(),
|
||||
'params' => [
|
||||
'message' => 'Please provide your name.',
|
||||
'requestedSchema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string', 'title' => 'Name'],
|
||||
],
|
||||
'required' => ['name'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function callbackReturning(ElicitResult $result): ElicitationCallbackInterface
|
||||
{
|
||||
return new class($result) implements ElicitationCallbackInterface {
|
||||
public function __construct(private readonly ElicitResult $result)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(ElicitRequest $request): ElicitResult
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function callbackThrowing(\Throwable $exception): ElicitationCallbackInterface
|
||||
{
|
||||
return new class($exception) implements ElicitationCallbackInterface {
|
||||
public function __construct(private readonly \Throwable $exception)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(ElicitRequest $request): ElicitResult
|
||||
{
|
||||
throw $this->exception;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue