From a3ea988c12f64de2621a72672ced553fe5b7e7ae Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Sat, 24 Jan 2026 22:16:56 +0100 Subject: [PATCH] Add elicitation support for server-to-client user input requests - Add ElicitAction enum (accept/decline/cancel) - Add schema definitions for elicitation fields (string, number, boolean, enum) - Add ElicitationSchema wrapper for requestedSchema - Add ElicitRequest and ElicitResult for elicitation/create method - Update ClientCapabilities to support elicitation capability - Add elicit() method to ClientGateway - Store client capabilities in session during initialization - Add comprehensive unit tests - Add elicitation example server with book_restaurant, confirm_action, and collect_feedback tools --- composer.json | 1 + .../elicitation/ElicitationHandlers.php | 275 ++++++++++++++++++ examples/server/elicitation/server.php | 61 ++++ src/Schema/ClientCapabilities.php | 13 + .../Elicitation/BooleanSchemaDefinition.php | 82 ++++++ src/Schema/Elicitation/ElicitationSchema.php | 109 +++++++ .../Elicitation/EnumSchemaDefinition.php | 123 ++++++++ .../Elicitation/NumberSchemaDefinition.php | 112 +++++++ .../Elicitation/PrimitiveSchemaDefinition.php | 62 ++++ .../Elicitation/StringSchemaDefinition.php | 132 +++++++++ src/Schema/Enum/ElicitAction.php | 17 ++ src/Schema/Request/ElicitRequest.php | 74 +++++ src/Schema/Result/ElicitResult.php | 94 ++++++ src/Server/ClientGateway.php | 30 ++ .../Handler/Request/InitializeHandler.php | 1 + .../BooleanSchemaDefinitionTest.php | 110 +++++++ .../Elicitation/ElicitationSchemaTest.php | 221 ++++++++++++++ .../Elicitation/EnumSchemaDefinitionTest.php | 167 +++++++++++ .../NumberSchemaDefinitionTest.php | 169 +++++++++++ .../PrimitiveSchemaDefinitionTest.php | 115 ++++++++ .../StringSchemaDefinitionTest.php | 159 ++++++++++ tests/Unit/Schema/Enum/ElicitActionTest.php | 52 ++++ .../Unit/Schema/Request/ElicitRequestTest.php | 74 +++++ tests/Unit/Schema/Result/ElicitResultTest.php | 155 ++++++++++ .../Handler/Request/InitializeHandlerTest.php | 13 +- 25 files changed, 2416 insertions(+), 5 deletions(-) create mode 100644 examples/server/elicitation/ElicitationHandlers.php create mode 100644 examples/server/elicitation/server.php create mode 100644 src/Schema/Elicitation/BooleanSchemaDefinition.php create mode 100644 src/Schema/Elicitation/ElicitationSchema.php create mode 100644 src/Schema/Elicitation/EnumSchemaDefinition.php create mode 100644 src/Schema/Elicitation/NumberSchemaDefinition.php create mode 100644 src/Schema/Elicitation/PrimitiveSchemaDefinition.php create mode 100644 src/Schema/Elicitation/StringSchemaDefinition.php create mode 100644 src/Schema/Enum/ElicitAction.php create mode 100644 src/Schema/Request/ElicitRequest.php create mode 100644 src/Schema/Result/ElicitResult.php create mode 100644 tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php create mode 100644 tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php create mode 100644 tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php create mode 100644 tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php create mode 100644 tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php create mode 100644 tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php create mode 100644 tests/Unit/Schema/Enum/ElicitActionTest.php create mode 100644 tests/Unit/Schema/Request/ElicitRequestTest.php create mode 100644 tests/Unit/Schema/Result/ElicitResultTest.php diff --git a/composer.json b/composer.json index e0c2edbc..f31e3fbd 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,7 @@ "Mcp\\Example\\Server\\ClientCommunication\\": "examples/server/client-communication/", "Mcp\\Example\\Server\\ClientLogging\\": "examples/server/client-logging/", "Mcp\\Example\\Server\\CombinedRegistration\\": "examples/server/combined-registration/", + "Mcp\\Example\\Server\\Elicitation\\": "examples/server/elicitation/", "Mcp\\Example\\Server\\ComplexToolSchema\\": "examples/server/complex-tool-schema/", "Mcp\\Example\\Server\\Conformance\\": "examples/server/conformance/", "Mcp\\Example\\Server\\CustomDependencies\\": "examples/server/custom-dependencies/", diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php new file mode 100644 index 00000000..0d9804e4 --- /dev/null +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -0,0 +1,275 @@ +logger->info('ElicitationHandlers instantiated.'); + } + + /** + * Check if the client supports elicitation. + */ + private function clientSupportsElicitation(RequestContext $context): bool + { + $capabilities = $context->getSession()->get('client_capabilities', []); + + // MCP spec: capability presence indicates support (value is typically {} or []) + return \array_key_exists('elicitation', $capabilities); + } + + /** + * Book a restaurant reservation with user elicitation. + * + * Demonstrates multi-field elicitation with different field types: + * - Number field for party size with validation + * - String field with date format for reservation date + * - Enum field for dietary restrictions with human-readable labels + * + * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}} + */ + #[McpTool('book_restaurant', 'Book a restaurant reservation, collecting details via elicitation.')] + public function bookRestaurant(RequestContext $context, string $restaurantName): array + { + if (!$this->clientSupportsElicitation($context)) { + return [ + 'status' => 'error', + 'message' => 'Client does not support elicitation. Please provide reservation details (party_size, date, dietary) as tool parameters instead.', + ]; + } + + $client = $context->getClientGateway(); + + $this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName)); + + $schema = new ElicitationSchema( + properties: [ + 'party_size' => new NumberSchemaDefinition( + title: 'Party Size', + integerOnly: true, + description: 'Number of guests in your party', + default: 2, + minimum: 1, + maximum: 20, + ), + 'date' => new StringSchemaDefinition( + title: 'Reservation Date', + description: 'Preferred date for your reservation', + format: 'date', + ), + 'dietary' => new EnumSchemaDefinition( + title: 'Dietary Restrictions', + enum: ['none', 'vegetarian', 'vegan', 'gluten-free', 'halal', 'kosher'], + description: 'Any dietary restrictions or preferences', + default: 'none', + enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'], + ), + ], + required: ['party_size', 'date'], + ); + + $result = $client->elicit( + message: \sprintf('Please provide your reservation details for %s:', $restaurantName), + requestedSchema: $schema, + timeout: 120, + ); + + if ($result->isDeclined()) { + $this->logger->info('User declined to provide reservation details.'); + + return [ + 'status' => 'declined', + 'message' => 'Reservation request was declined by user.', + ]; + } + + if ($result->isCancelled()) { + $this->logger->info('User cancelled the reservation request.'); + + return [ + 'status' => 'cancelled', + 'message' => 'Reservation request was cancelled.', + ]; + } + + $content = $result->content ?? []; + $partySize = (int) ($content['party_size'] ?? 2); + $date = (string) ($content['date'] ?? ''); + $dietary = (string) ($content['dietary'] ?? 'none'); + + $this->logger->info(\sprintf( + 'Booking confirmed: %d guests on %s with %s dietary requirements', + $partySize, + $date, + $dietary, + )); + + return [ + 'status' => 'confirmed', + 'message' => \sprintf( + 'Reservation confirmed at %s for %d guests on %s.', + $restaurantName, + $partySize, + $date, + ), + 'booking' => [ + 'party_size' => $partySize, + 'date' => $date, + 'dietary' => $dietary, + ], + ]; + } + + /** + * Confirm an action with a simple boolean elicitation. + * + * Demonstrates the simplest elicitation pattern - a yes/no confirmation. + * + * @return array{status: string, message: string} + */ + #[McpTool('confirm_action', 'Request user confirmation before proceeding with an action.')] + public function confirmAction(RequestContext $context, string $actionDescription): array + { + if (!$this->clientSupportsElicitation($context)) { + return [ + 'status' => 'error', + 'message' => 'Client does not support elicitation. Please confirm the action explicitly in your request.', + ]; + } + + $client = $context->getClientGateway(); + + $schema = new ElicitationSchema( + properties: [ + 'confirm' => new BooleanSchemaDefinition( + title: 'Confirm', + description: 'Check to confirm you want to proceed', + default: false, + ), + ], + required: ['confirm'], + ); + + $result = $client->elicit( + message: \sprintf('Are you sure you want to: %s?', $actionDescription), + requestedSchema: $schema, + ); + + if (!$result->isAccepted()) { + return [ + 'status' => 'not_confirmed', + 'message' => 'Action was not confirmed by user.', + ]; + } + + $confirmed = (bool) ($result->content['confirm'] ?? false); + + if (!$confirmed) { + return [ + 'status' => 'not_confirmed', + 'message' => 'User did not check the confirmation box.', + ]; + } + + $this->logger->info(\sprintf('User confirmed action: %s', $actionDescription)); + + return [ + 'status' => 'confirmed', + 'message' => \sprintf('Action confirmed: %s', $actionDescription), + ]; + } + + /** + * Collect user feedback using elicitation. + * + * Demonstrates elicitation with optional fields and enum with labels. + * + * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}} + */ + #[McpTool('collect_feedback', 'Collect user feedback via elicitation form.')] + public function collectFeedback(RequestContext $context, string $topic): array + { + if (!$this->clientSupportsElicitation($context)) { + return [ + 'status' => 'error', + 'message' => 'Client does not support elicitation. Please provide feedback (rating 1-5, comments) as tool parameters instead.', + ]; + } + + $client = $context->getClientGateway(); + + $schema = new ElicitationSchema( + properties: [ + 'rating' => new EnumSchemaDefinition( + title: 'Rating', + enum: ['1', '2', '3', '4', '5'], + description: 'Rate your experience from 1 (poor) to 5 (excellent)', + enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent'], + ), + 'comments' => new StringSchemaDefinition( + title: 'Comments', + description: 'Any additional comments or suggestions (optional)', + maxLength: 500, + ), + ], + required: ['rating'], + ); + + $result = $client->elicit( + message: \sprintf('Please provide your feedback about: %s', $topic), + requestedSchema: $schema, + ); + + if (!$result->isAccepted()) { + return [ + 'status' => 'skipped', + 'message' => 'User chose not to provide feedback.', + ]; + } + + $content = $result->content ?? []; + $rating = (string) ($content['rating'] ?? '3'); + $comments = (string) ($content['comments'] ?? ''); + + $this->logger->info(\sprintf('Feedback received: rating=%s, comments=%s', $rating, $comments)); + + return [ + 'status' => 'received', + 'message' => 'Thank you for your feedback!', + 'feedback' => [ + 'rating' => $rating, + 'comments' => $comments, + ], + ]; + } +} diff --git a/examples/server/elicitation/server.php b/examples/server/elicitation/server.php new file mode 100644 index 00000000..410396d8 --- /dev/null +++ b/examples/server/elicitation/server.php @@ -0,0 +1,61 @@ +#!/usr/bin/env php +setServerInfo('Elicitation Demo', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + // Session store is REQUIRED for server-to-client requests like elicitation + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setCapabilities(new ServerCapabilities(logging: true, tools: true)) + // Auto-discover tools from ElicitationHandlers class + ->setDiscovery(__DIR__) + ->build(); + +$result = $server->run(transport()); + +shutdown($result); diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 0428c2eb..f6f277a0 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -26,6 +26,7 @@ public function __construct( public readonly ?bool $roots = false, public readonly ?bool $rootsListChanged = null, public readonly ?bool $sampling = null, + public readonly ?bool $elicitation = null, public readonly ?array $experimental = null, ) { } @@ -36,6 +37,7 @@ public function __construct( * listChanged?: bool, * }, * sampling?: bool, + * elicitation?: bool, * experimental?: array, * } $data */ @@ -56,10 +58,16 @@ public static function fromArray(array $data): self $sampling = true; } + $elicitation = null; + if (isset($data['elicitation'])) { + $elicitation = true; + } + return new self( $rootsEnabled, $rootsListChanged, $sampling, + $elicitation, $data['experimental'] ?? null ); } @@ -68,6 +76,7 @@ public static function fromArray(array $data): self * @return array{ * roots?: object, * sampling?: object, + * elicitation?: object, * experimental?: object, * } */ @@ -85,6 +94,10 @@ public function jsonSerialize(): array $data['sampling'] = new \stdClass(); } + if ($this->elicitation) { + $data['elicitation'] = new \stdClass(); + } + if ($this->experimental) { $data['experimental'] = (object) $this->experimental; } diff --git a/src/Schema/Elicitation/BooleanSchemaDefinition.php b/src/Schema/Elicitation/BooleanSchemaDefinition.php new file mode 100644 index 00000000..4aabff89 --- /dev/null +++ b/src/Schema/Elicitation/BooleanSchemaDefinition.php @@ -0,0 +1,82 @@ + 'boolean', + 'title' => $this->title, + ]; + + if (null !== $this->description) { + $data['description'] = $this->description; + } + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php new file mode 100644 index 00000000..d902b3e3 --- /dev/null +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -0,0 +1,109 @@ + $properties Property definitions keyed by name + * @param string[] $required Array of required property names + */ + public function __construct( + public readonly array $properties, + public readonly array $required = [], + ) { + if ([] === $properties) { + throw new InvalidArgumentException('properties array must not be empty.'); + } + + foreach ($required as $name) { + if (!\array_key_exists($name, $properties)) { + throw new InvalidArgumentException(\sprintf( + 'Required property "%s" is not defined in properties.', + $name + )); + } + } + } + + /** + * Create an ElicitationSchema from array data. + * + * @param array{ + * type?: string, + * properties: array, + * required?: string[], + * } $data + */ + public static function fromArray(array $data): self + { + if (isset($data['type']) && 'object' !== $data['type']) { + throw new InvalidArgumentException('ElicitationSchema type must be "object".'); + } + + if (!isset($data['properties']) || !\is_array($data['properties'])) { + throw new InvalidArgumentException('Missing or invalid "properties" for elicitation schema.'); + } + + $properties = []; + foreach ($data['properties'] as $name => $propertyData) { + if (!\is_array($propertyData)) { + throw new InvalidArgumentException(\sprintf( + 'Property "%s" must be an array.', + $name + )); + } + $properties[$name] = PrimitiveSchemaDefinition::fromArray($propertyData); + } + + return new self( + properties: $properties, + required: $data['required'] ?? [], + ); + } + + /** + * @return array{ + * type: string, + * properties: array, + * required?: string[], + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => 'object', + 'properties' => [], + ]; + + foreach ($this->properties as $name => $property) { + $data['properties'][$name] = $property->jsonSerialize(); + } + + if ([] !== $this->required) { + $data['required'] = $this->required; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/EnumSchemaDefinition.php b/src/Schema/Elicitation/EnumSchemaDefinition.php new file mode 100644 index 00000000..748d1fe3 --- /dev/null +++ b/src/Schema/Elicitation/EnumSchemaDefinition.php @@ -0,0 +1,123 @@ + 'string', + 'title' => $this->title, + 'enum' => $this->enum, + ]; + + if (null !== $this->description) { + $data['description'] = $this->description; + } + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + if (null !== $this->enumNames) { + $data['enumNames'] = $this->enumNames; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/NumberSchemaDefinition.php b/src/Schema/Elicitation/NumberSchemaDefinition.php new file mode 100644 index 00000000..fca93abe --- /dev/null +++ b/src/Schema/Elicitation/NumberSchemaDefinition.php @@ -0,0 +1,112 @@ + $maximum) { + throw new InvalidArgumentException('minimum cannot be greater than maximum.'); + } + } + + /** + * @param array{ + * type: string, + * title: string, + * description?: string, + * default?: int|float, + * minimum?: int|float, + * maximum?: int|float, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['title']) || !\is_string($data['title'])) { + throw new InvalidArgumentException('Missing or invalid "title" for number schema definition.'); + } + + $type = $data['type'] ?? 'number'; + $integerOnly = 'integer' === $type; + + return new self( + title: $data['title'], + integerOnly: $integerOnly, + description: $data['description'] ?? null, + default: $data['default'] ?? null, + minimum: $data['minimum'] ?? null, + maximum: $data['maximum'] ?? null, + ); + } + + /** + * @return array{ + * type: string, + * title: string, + * description?: string, + * default?: int|float, + * minimum?: int|float, + * maximum?: int|float, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->integerOnly ? 'integer' : 'number', + 'title' => $this->title, + ]; + + if (null !== $this->description) { + $data['description'] = $this->description; + } + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + if (null !== $this->minimum) { + $data['minimum'] = $this->minimum; + } + + if (null !== $this->maximum) { + $data['maximum'] = $this->maximum; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/PrimitiveSchemaDefinition.php b/src/Schema/Elicitation/PrimitiveSchemaDefinition.php new file mode 100644 index 00000000..4ace2404 --- /dev/null +++ b/src/Schema/Elicitation/PrimitiveSchemaDefinition.php @@ -0,0 +1,62 @@ + isset($data['enum']) ? EnumSchemaDefinition::fromArray($data) : StringSchemaDefinition::fromArray($data), + 'integer', 'number' => NumberSchemaDefinition::fromArray($data), + 'boolean' => BooleanSchemaDefinition::fromArray($data), + default => throw new InvalidArgumentException(\sprintf( + 'Unsupported primitive type "%s". Supported types are: string, integer, number, boolean.', + $data['type'] + )), + }; + } +} diff --git a/src/Schema/Elicitation/StringSchemaDefinition.php b/src/Schema/Elicitation/StringSchemaDefinition.php new file mode 100644 index 00000000..c68b9297 --- /dev/null +++ b/src/Schema/Elicitation/StringSchemaDefinition.php @@ -0,0 +1,132 @@ + $maxLength) { + throw new InvalidArgumentException('minLength cannot be greater than maxLength.'); + } + } + + /** + * @param array{ + * title: string, + * description?: string, + * default?: string, + * format?: string, + * minLength?: int, + * maxLength?: int, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['title']) || !\is_string($data['title'])) { + throw new InvalidArgumentException('Missing or invalid "title" for string schema definition.'); + } + + return new self( + title: $data['title'], + description: $data['description'] ?? null, + default: $data['default'] ?? null, + format: $data['format'] ?? null, + minLength: isset($data['minLength']) ? (int) $data['minLength'] : null, + maxLength: isset($data['maxLength']) ? (int) $data['maxLength'] : null, + ); + } + + /** + * @return array{ + * type: string, + * title: string, + * description?: string, + * default?: string, + * format?: string, + * minLength?: int, + * maxLength?: int, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => 'string', + 'title' => $this->title, + ]; + + if (null !== $this->description) { + $data['description'] = $this->description; + } + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + if (null !== $this->format) { + $data['format'] = $this->format; + } + + if (null !== $this->minLength) { + $data['minLength'] = $this->minLength; + } + + if (null !== $this->maxLength) { + $data['maxLength'] = $this->maxLength; + } + + return $data; + } +} diff --git a/src/Schema/Enum/ElicitAction.php b/src/Schema/Enum/ElicitAction.php new file mode 100644 index 00000000..f317a7c0 --- /dev/null +++ b/src/Schema/Enum/ElicitAction.php @@ -0,0 +1,17 @@ + $this->message, + 'requestedSchema' => $this->requestedSchema, + ]; + } +} diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php new file mode 100644 index 00000000..0273a2e3 --- /dev/null +++ b/src/Schema/Result/ElicitResult.php @@ -0,0 +1,94 @@ +|null $content The content provided by the user (only present when action is "accept") + */ + public function __construct( + public readonly ElicitAction $action, + public readonly ?array $content = null, + ) { + } + + /** + * @param array{action: string, content?: array} $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['action']) || !\is_string($data['action'])) { + throw new InvalidArgumentException('Missing or invalid "action" in ElicitResult data.'); + } + + $action = ElicitAction::from($data['action']); + $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; + + return new self($action, $content); + } + + /** + * Check if the user accepted the elicitation request. + */ + public function isAccepted(): bool + { + return ElicitAction::Accept === $this->action; + } + + /** + * Check if the user declined the elicitation request. + */ + public function isDeclined(): bool + { + return ElicitAction::Decline === $this->action; + } + + /** + * Check if the user cancelled the elicitation request. + */ + public function isCancelled(): bool + { + return ElicitAction::Cancel === $this->action; + } + + /** + * @return array{action: string, content?: array} + */ + public function jsonSerialize(): array + { + $result = [ + 'action' => $this->action->value, + ]; + + if (null !== $this->content) { + $result['content'] = $this->content; + } + + return $result; + } +} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 4b4eebf0..713c9445 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -19,6 +19,7 @@ use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\Role; use Mcp\Schema\Enum\SamplingContext; @@ -30,7 +31,9 @@ use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Notification\ProgressNotification; use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; +use Mcp\Schema\Result\ElicitResult; use Mcp\Server\Session\SessionInterface; /** @@ -158,6 +161,33 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int return CreateSamplingMessageResult::fromArray($response->result); } + /** + * Convenience method for elicitation requests. + * + * Requests additional information from the user via the client. The user can + * accept (providing the requested data), decline, or cancel the request. + * + * @param string $message A human-readable message describing what information is needed + * @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user + * @param int $timeout The timeout in seconds + * + * @return ElicitResult The elicitation response containing the user's action and any provided content + * + * @throws ClientException if the client request results in an error message + */ + public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120): ElicitResult + { + $request = new ElicitRequest($message, $requestedSchema); + + $response = $this->request($request, $timeout); + + if ($response instanceof Error) { + throw new ClientException($response); + } + + return ElicitResult::fromArray($response->result); + } + /** * Send a request to the client and wait for a response (blocking). * diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index d814d9dd..f7c7eac6 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -45,6 +45,7 @@ public function handle(Request $request, SessionInterface $session): Response \assert($request instanceof InitializeRequest); $session->set('client_info', $request->clientInfo->jsonSerialize()); + $session->set('client_capabilities', $request->capabilities->jsonSerialize()); return new Response( $request->getId(), diff --git a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php new file mode 100644 index 00000000..b6fc6f07 --- /dev/null +++ b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php @@ -0,0 +1,110 @@ +assertSame('Confirm', $schema->title); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + } + + public function testConstructorWithAllParams(): void + { + $schema = new BooleanSchemaDefinition( + title: 'Confirmation', + description: 'Do you confirm this action?', + default: false, + ); + + $this->assertSame('Confirmation', $schema->title); + $this->assertSame('Do you confirm this action?', $schema->description); + $this->assertFalse($schema->default); + } + + public function testConstructorWithTrueDefault(): void + { + $schema = new BooleanSchemaDefinition( + title: 'Subscribe', + default: true, + ); + + $this->assertTrue($schema->default); + } + + public function testFromArrayWithMinimalParams(): void + { + $schema = BooleanSchemaDefinition::fromArray(['title' => 'Confirm']); + + $this->assertSame('Confirm', $schema->title); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + } + + public function testFromArrayWithAllParams(): void + { + $schema = BooleanSchemaDefinition::fromArray([ + 'title' => 'Confirmation', + 'description' => 'Do you confirm this action?', + 'default' => true, + ]); + + $this->assertSame('Confirmation', $schema->title); + $this->assertSame('Do you confirm this action?', $schema->description); + $this->assertTrue($schema->default); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + BooleanSchemaDefinition::fromArray([]); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $schema = new BooleanSchemaDefinition('Confirm'); + + $this->assertSame([ + 'type' => 'boolean', + 'title' => 'Confirm', + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $schema = new BooleanSchemaDefinition( + title: 'Confirmation', + description: 'Do you confirm this action?', + default: false, + ); + + $this->assertSame([ + 'type' => 'boolean', + 'title' => 'Confirmation', + 'description' => 'Do you confirm this action?', + 'default' => false, + ], $schema->jsonSerialize()); + } +} diff --git a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php new file mode 100644 index 00000000..1f15c7b1 --- /dev/null +++ b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php @@ -0,0 +1,221 @@ + new StringSchemaDefinition('Name'), + ]; + + $schema = new ElicitationSchema($properties); + + $this->assertCount(1, $schema->properties); + $this->assertSame([], $schema->required); + } + + public function testConstructorWithRequiredFields(): void + { + $properties = [ + 'name' => new StringSchemaDefinition('Name'), + 'email' => new StringSchemaDefinition('Email'), + ]; + + $schema = new ElicitationSchema($properties, ['name']); + + $this->assertCount(2, $schema->properties); + $this->assertSame(['name'], $schema->required); + } + + public function testConstructorWithMultipleTypes(): void + { + $properties = [ + 'name' => new StringSchemaDefinition('Name'), + 'age' => new NumberSchemaDefinition('Age', integerOnly: true), + 'subscribe' => new BooleanSchemaDefinition('Subscribe'), + 'rating' => new EnumSchemaDefinition('Rating', ['1', '2', '3', '4', '5']), + ]; + + $schema = new ElicitationSchema($properties, ['name', 'age']); + + $this->assertCount(4, $schema->properties); + $this->assertInstanceOf(StringSchemaDefinition::class, $schema->properties['name']); + $this->assertInstanceOf(NumberSchemaDefinition::class, $schema->properties['age']); + $this->assertInstanceOf(BooleanSchemaDefinition::class, $schema->properties['subscribe']); + $this->assertInstanceOf(EnumSchemaDefinition::class, $schema->properties['rating']); + } + + public function testConstructorWithEmptyProperties(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('properties array must not be empty'); + + new ElicitationSchema([]); + } + + public function testConstructorWithInvalidRequired(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required property "unknown" is not defined in properties'); + + new ElicitationSchema( + ['name' => new StringSchemaDefinition('Name')], + ['unknown'], + ); + } + + public function testFromArrayWithMinimalParams(): void + { + $schema = ElicitationSchema::fromArray([ + 'properties' => [ + 'name' => ['type' => 'string', 'title' => 'Name'], + ], + ]); + + $this->assertCount(1, $schema->properties); + $this->assertInstanceOf(StringSchemaDefinition::class, $schema->properties['name']); + } + + public function testFromArrayWithExplicitObjectType(): void + { + $schema = ElicitationSchema::fromArray([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'title' => 'Name'], + ], + ]); + + $this->assertCount(1, $schema->properties); + } + + public function testFromArrayWithInvalidType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ElicitationSchema type must be "object"'); + + ElicitationSchema::fromArray([ + 'type' => 'array', + 'properties' => [ + 'name' => ['type' => 'string', 'title' => 'Name'], + ], + ]); + } + + public function testFromArrayWithMissingProperties(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "properties"'); + + /* @phpstan-ignore argument.type */ + ElicitationSchema::fromArray([]); + } + + public function testFromArrayWithRequiredFields(): void + { + $schema = ElicitationSchema::fromArray([ + 'properties' => [ + 'name' => ['type' => 'string', 'title' => 'Name'], + 'email' => ['type' => 'string', 'title' => 'Email', 'format' => 'email'], + ], + 'required' => ['name'], + ]); + + $this->assertSame(['name'], $schema->required); + } + + public function testFromArrayWithMultipleTypes(): void + { + $schema = ElicitationSchema::fromArray([ + 'properties' => [ + 'name' => ['type' => 'string', 'title' => 'Name'], + 'age' => ['type' => 'integer', 'title' => 'Age', 'minimum' => 0], + 'confirm' => ['type' => 'boolean', 'title' => 'Confirm'], + 'rating' => ['type' => 'string', 'title' => 'Rating', 'enum' => ['1', '2', '3']], + ], + ]); + + $this->assertInstanceOf(StringSchemaDefinition::class, $schema->properties['name']); + $this->assertInstanceOf(NumberSchemaDefinition::class, $schema->properties['age']); + $this->assertInstanceOf(BooleanSchemaDefinition::class, $schema->properties['confirm']); + $this->assertInstanceOf(EnumSchemaDefinition::class, $schema->properties['rating']); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $schema = new ElicitationSchema([ + 'name' => new StringSchemaDefinition('Name'), + ]); + + $result = $schema->jsonSerialize(); + + $this->assertSame('object', $result['type']); + $this->assertArrayHasKey('name', $result['properties']); + $this->assertSame('string', $result['properties']['name']['type']); + $this->assertArrayNotHasKey('required', $result); + } + + public function testJsonSerializeWithRequiredFields(): void + { + $schema = new ElicitationSchema( + [ + 'name' => new StringSchemaDefinition('Name'), + 'email' => new StringSchemaDefinition('Email'), + ], + ['name'], + ); + + $result = $schema->jsonSerialize(); + + $this->assertSame(['name'], $result['required']); + } + + public function testJsonSerializeWithFullSchema(): void + { + $schema = new ElicitationSchema( + [ + 'name' => new StringSchemaDefinition('Full Name', description: 'Your full name'), + 'age' => new NumberSchemaDefinition('Age', integerOnly: true, minimum: 0, maximum: 150), + 'subscribe' => new BooleanSchemaDefinition('Subscribe', default: false), + ], + ['name', 'age'], + ); + + $result = $schema->jsonSerialize(); + + $this->assertSame('object', $result['type']); + $this->assertCount(3, $result['properties']); + $this->assertSame(['name', 'age'], $result['required']); + + $this->assertSame('string', $result['properties']['name']['type']); + $this->assertSame('Full Name', $result['properties']['name']['title']); + $this->assertSame('Your full name', $result['properties']['name']['description']); + + $this->assertSame('integer', $result['properties']['age']['type']); + $this->assertSame(0, $result['properties']['age']['minimum']); + $this->assertSame(150, $result['properties']['age']['maximum']); + + $this->assertSame('boolean', $result['properties']['subscribe']['type']); + $this->assertFalse($result['properties']['subscribe']['default']); + } +} diff --git a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php new file mode 100644 index 00000000..0b90566e --- /dev/null +++ b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php @@ -0,0 +1,167 @@ +assertSame('Rating', $schema->title); + $this->assertSame(['1', '2', '3', '4', '5'], $schema->enum); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->enumNames); + } + + public function testConstructorWithAllParams(): void + { + $schema = new EnumSchemaDefinition( + title: 'Satisfaction', + enum: ['poor', 'fair', 'good', 'excellent'], + description: 'Rate your satisfaction', + default: 'good', + enumNames: ['Poor', 'Fair', 'Good', 'Excellent'], + ); + + $this->assertSame('Satisfaction', $schema->title); + $this->assertSame(['poor', 'fair', 'good', 'excellent'], $schema->enum); + $this->assertSame('Rate your satisfaction', $schema->description); + $this->assertSame('good', $schema->default); + $this->assertSame(['Poor', 'Fair', 'Good', 'Excellent'], $schema->enumNames); + } + + public function testConstructorWithEmptyEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('enum array must not be empty'); + + new EnumSchemaDefinition('Test', []); + } + + public function testConstructorWithNonStringEnumValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All enum values must be strings'); + + /* @phpstan-ignore argument.type */ + new EnumSchemaDefinition('Test', ['a', 1, 'b']); + } + + public function testConstructorWithEnumNamesMismatch(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('enumNames length must match enum length'); + + new EnumSchemaDefinition( + title: 'Test', + enum: ['a', 'b', 'c'], + enumNames: ['A', 'B'], + ); + } + + public function testConstructorWithInvalidDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Default value "invalid" is not in the enum array'); + + new EnumSchemaDefinition( + title: 'Test', + enum: ['a', 'b', 'c'], + default: 'invalid', + ); + } + + public function testFromArrayWithMinimalParams(): void + { + $schema = EnumSchemaDefinition::fromArray([ + 'title' => 'Rating', + 'enum' => ['1', '2', '3'], + ]); + + $this->assertSame('Rating', $schema->title); + $this->assertSame(['1', '2', '3'], $schema->enum); + } + + public function testFromArrayWithAllParams(): void + { + $schema = EnumSchemaDefinition::fromArray([ + 'title' => 'Satisfaction', + 'enum' => ['poor', 'fair', 'good'], + 'description' => 'Rate your satisfaction', + 'default' => 'fair', + 'enumNames' => ['Poor', 'Fair', 'Good'], + ]); + + $this->assertSame('Satisfaction', $schema->title); + $this->assertSame(['poor', 'fair', 'good'], $schema->enum); + $this->assertSame('Rate your satisfaction', $schema->description); + $this->assertSame('fair', $schema->default); + $this->assertSame(['Poor', 'Fair', 'Good'], $schema->enumNames); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]); + } + + public function testFromArrayWithMissingEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "enum"'); + + /* @phpstan-ignore argument.type */ + EnumSchemaDefinition::fromArray(['title' => 'Test']); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $schema = new EnumSchemaDefinition('Rating', ['1', '2', '3']); + + $this->assertSame([ + 'type' => 'string', + 'title' => 'Rating', + 'enum' => ['1', '2', '3'], + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $schema = new EnumSchemaDefinition( + title: 'Satisfaction', + enum: ['poor', 'fair', 'good'], + description: 'Rate your satisfaction', + default: 'fair', + enumNames: ['Poor', 'Fair', 'Good'], + ); + + $this->assertSame([ + 'type' => 'string', + 'title' => 'Satisfaction', + 'enum' => ['poor', 'fair', 'good'], + 'description' => 'Rate your satisfaction', + 'default' => 'fair', + 'enumNames' => ['Poor', 'Fair', 'Good'], + ], $schema->jsonSerialize()); + } +} diff --git a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php new file mode 100644 index 00000000..c9d675db --- /dev/null +++ b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php @@ -0,0 +1,169 @@ +assertSame('Age', $schema->title); + $this->assertFalse($schema->integerOnly); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->minimum); + $this->assertNull($schema->maximum); + } + + public function testConstructorWithAllParams(): void + { + $schema = new NumberSchemaDefinition( + title: 'Party Size', + integerOnly: true, + description: 'Number of guests', + default: 2, + minimum: 1, + maximum: 10, + ); + + $this->assertSame('Party Size', $schema->title); + $this->assertTrue($schema->integerOnly); + $this->assertSame('Number of guests', $schema->description); + $this->assertSame(2, $schema->default); + $this->assertSame(1, $schema->minimum); + $this->assertSame(10, $schema->maximum); + } + + public function testConstructorWithFloatValues(): void + { + $schema = new NumberSchemaDefinition( + title: 'Temperature', + default: 36.5, + minimum: 35.0, + maximum: 42.0, + ); + + $this->assertSame(36.5, $schema->default); + $this->assertSame(35.0, $schema->minimum); + $this->assertSame(42.0, $schema->maximum); + } + + public function testConstructorWithMinimumGreaterThanMaximum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minimum cannot be greater than maximum'); + + new NumberSchemaDefinition('Test', minimum: 10, maximum: 5); + } + + public function testFromArrayWithIntegerType(): void + { + $schema = NumberSchemaDefinition::fromArray([ + 'type' => 'integer', + 'title' => 'Count', + ]); + + $this->assertTrue($schema->integerOnly); + } + + public function testFromArrayWithNumberType(): void + { + $schema = NumberSchemaDefinition::fromArray([ + 'type' => 'number', + 'title' => 'Price', + ]); + + $this->assertFalse($schema->integerOnly); + } + + public function testFromArrayWithAllParams(): void + { + $schema = NumberSchemaDefinition::fromArray([ + 'type' => 'integer', + 'title' => 'Party Size', + 'description' => 'Number of guests', + 'default' => 2, + 'minimum' => 1, + 'maximum' => 10, + ]); + + $this->assertSame('Party Size', $schema->title); + $this->assertTrue($schema->integerOnly); + $this->assertSame('Number of guests', $schema->description); + $this->assertSame(2, $schema->default); + $this->assertSame(1, $schema->minimum); + $this->assertSame(10, $schema->maximum); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + NumberSchemaDefinition::fromArray(['type' => 'integer']); + } + + public function testJsonSerializeAsInteger(): void + { + $schema = new NumberSchemaDefinition( + title: 'Count', + integerOnly: true, + ); + + $this->assertSame([ + 'type' => 'integer', + 'title' => 'Count', + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeAsNumber(): void + { + $schema = new NumberSchemaDefinition( + title: 'Price', + integerOnly: false, + ); + + $this->assertSame([ + 'type' => 'number', + 'title' => 'Price', + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $schema = new NumberSchemaDefinition( + title: 'Party Size', + integerOnly: true, + description: 'Number of guests', + default: 2, + minimum: 1, + maximum: 10, + ); + + $this->assertSame([ + 'type' => 'integer', + 'title' => 'Party Size', + 'description' => 'Number of guests', + 'default' => 2, + 'minimum' => 1, + 'maximum' => 10, + ], $schema->jsonSerialize()); + } +} diff --git a/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php new file mode 100644 index 00000000..cbe5f06d --- /dev/null +++ b/tests/Unit/Schema/Elicitation/PrimitiveSchemaDefinitionTest.php @@ -0,0 +1,115 @@ + 'string', + 'title' => 'Name', + ]); + + $this->assertInstanceOf(StringSchemaDefinition::class, $schema); + $this->assertSame('Name', $schema->title); + } + + public function testFromArrayCreatesEnumSchemaForStringWithEnum(): void + { + $schema = PrimitiveSchemaDefinition::fromArray([ + 'type' => 'string', + 'title' => 'Rating', + 'enum' => ['1', '2', '3'], + ]); + + $this->assertInstanceOf(EnumSchemaDefinition::class, $schema); + $this->assertSame('Rating', $schema->title); + $this->assertSame(['1', '2', '3'], $schema->enum); + } + + public function testFromArrayCreatesIntegerSchema(): void + { + $schema = PrimitiveSchemaDefinition::fromArray([ + 'type' => 'integer', + 'title' => 'Age', + ]); + + $this->assertInstanceOf(NumberSchemaDefinition::class, $schema); + $this->assertSame('Age', $schema->title); + $this->assertTrue($schema->integerOnly); + } + + public function testFromArrayCreatesNumberSchema(): void + { + $schema = PrimitiveSchemaDefinition::fromArray([ + 'type' => 'number', + 'title' => 'Price', + ]); + + $this->assertInstanceOf(NumberSchemaDefinition::class, $schema); + $this->assertSame('Price', $schema->title); + $this->assertFalse($schema->integerOnly); + } + + public function testFromArrayCreatesBooleanSchema(): void + { + $schema = PrimitiveSchemaDefinition::fromArray([ + 'type' => 'boolean', + 'title' => 'Confirm', + ]); + + $this->assertInstanceOf(BooleanSchemaDefinition::class, $schema); + $this->assertSame('Confirm', $schema->title); + } + + public function testFromArrayWithMissingType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "type"'); + + /* @phpstan-ignore argument.type */ + PrimitiveSchemaDefinition::fromArray(['title' => 'Test']); + } + + public function testFromArrayWithUnsupportedType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported primitive type "object"'); + + PrimitiveSchemaDefinition::fromArray([ + 'type' => 'object', + 'title' => 'Test', + ]); + } + + public function testFromArrayWithArrayType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported primitive type "array"'); + + PrimitiveSchemaDefinition::fromArray([ + 'type' => 'array', + 'title' => 'Test', + ]); + } +} diff --git a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php new file mode 100644 index 00000000..eed896c3 --- /dev/null +++ b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php @@ -0,0 +1,159 @@ +assertSame('Name', $schema->title); + $this->assertNull($schema->description); + $this->assertNull($schema->default); + $this->assertNull($schema->format); + $this->assertNull($schema->minLength); + $this->assertNull($schema->maxLength); + } + + public function testConstructorWithAllParams(): void + { + $schema = new StringSchemaDefinition( + title: 'Email Address', + description: 'Your primary email', + default: 'user@example.com', + format: 'email', + minLength: 5, + maxLength: 100, + ); + + $this->assertSame('Email Address', $schema->title); + $this->assertSame('Your primary email', $schema->description); + $this->assertSame('user@example.com', $schema->default); + $this->assertSame('email', $schema->format); + $this->assertSame(5, $schema->minLength); + $this->assertSame(100, $schema->maxLength); + } + + public function testConstructorWithValidFormats(): void + { + foreach (['date', 'date-time', 'email', 'uri'] as $format) { + $schema = new StringSchemaDefinition('Test', format: $format); + $this->assertSame($format, $schema->format); + } + } + + public function testConstructorWithInvalidFormat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid format "invalid"'); + + new StringSchemaDefinition('Test', format: 'invalid'); + } + + public function testConstructorWithNegativeMinLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minLength must be non-negative'); + + new StringSchemaDefinition('Test', minLength: -1); + } + + public function testConstructorWithNegativeMaxLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('maxLength must be non-negative'); + + new StringSchemaDefinition('Test', maxLength: -1); + } + + public function testConstructorWithMinLengthGreaterThanMaxLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('minLength cannot be greater than maxLength'); + + new StringSchemaDefinition('Test', minLength: 10, maxLength: 5); + } + + public function testFromArrayWithMinimalParams(): void + { + $schema = StringSchemaDefinition::fromArray(['title' => 'Name']); + + $this->assertSame('Name', $schema->title); + } + + public function testFromArrayWithAllParams(): void + { + $schema = StringSchemaDefinition::fromArray([ + 'title' => 'Email Address', + 'description' => 'Your primary email', + 'default' => 'user@example.com', + 'format' => 'email', + 'minLength' => 5, + 'maxLength' => 100, + ]); + + $this->assertSame('Email Address', $schema->title); + $this->assertSame('Your primary email', $schema->description); + $this->assertSame('user@example.com', $schema->default); + $this->assertSame('email', $schema->format); + $this->assertSame(5, $schema->minLength); + $this->assertSame(100, $schema->maxLength); + } + + public function testFromArrayWithMissingTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "title"'); + + /* @phpstan-ignore argument.type */ + StringSchemaDefinition::fromArray([]); + } + + public function testJsonSerializeWithMinimalParams(): void + { + $schema = new StringSchemaDefinition('Name'); + + $this->assertSame([ + 'type' => 'string', + 'title' => 'Name', + ], $schema->jsonSerialize()); + } + + public function testJsonSerializeWithAllParams(): void + { + $schema = new StringSchemaDefinition( + title: 'Email Address', + description: 'Your primary email', + default: 'user@example.com', + format: 'email', + minLength: 5, + maxLength: 100, + ); + + $this->assertSame([ + 'type' => 'string', + 'title' => 'Email Address', + 'description' => 'Your primary email', + 'default' => 'user@example.com', + 'format' => 'email', + 'minLength' => 5, + 'maxLength' => 100, + ], $schema->jsonSerialize()); + } +} diff --git a/tests/Unit/Schema/Enum/ElicitActionTest.php b/tests/Unit/Schema/Enum/ElicitActionTest.php new file mode 100644 index 00000000..059ca4f2 --- /dev/null +++ b/tests/Unit/Schema/Enum/ElicitActionTest.php @@ -0,0 +1,52 @@ +assertSame('accept', ElicitAction::Accept->value); + $this->assertSame('decline', ElicitAction::Decline->value); + $this->assertSame('cancel', ElicitAction::Cancel->value); + } + + public function testFromValidValues(): void + { + $this->assertSame(ElicitAction::Accept, ElicitAction::from('accept')); + $this->assertSame(ElicitAction::Decline, ElicitAction::from('decline')); + $this->assertSame(ElicitAction::Cancel, ElicitAction::from('cancel')); + } + + public function testFromInvalidValue(): void + { + $this->expectException(\ValueError::class); + ElicitAction::from('invalid'); + } + + public function testTryFromValidValues(): void + { + $this->assertSame(ElicitAction::Accept, ElicitAction::tryFrom('accept')); + $this->assertSame(ElicitAction::Decline, ElicitAction::tryFrom('decline')); + $this->assertSame(ElicitAction::Cancel, ElicitAction::tryFrom('cancel')); + } + + public function testTryFromInvalidValue(): void + { + $this->assertNull(ElicitAction::tryFrom('invalid')); + } +} diff --git a/tests/Unit/Schema/Request/ElicitRequestTest.php b/tests/Unit/Schema/Request/ElicitRequestTest.php new file mode 100644 index 00000000..06cee6a9 --- /dev/null +++ b/tests/Unit/Schema/Request/ElicitRequestTest.php @@ -0,0 +1,74 @@ + new StringSchemaDefinition('Name'), + ]); + + $request = new ElicitRequest('Please provide your name', $schema); + + $this->assertSame('Please provide your name', $request->message); + $this->assertSame($schema, $request->requestedSchema); + } + + public function testGetMethod(): void + { + $this->assertSame('elicitation/create', ElicitRequest::getMethod()); + } + + public function testJsonSerialization(): void + { + $schema = new ElicitationSchema( + [ + 'name' => new StringSchemaDefinition('Name'), + 'age' => new NumberSchemaDefinition('Age', integerOnly: true, minimum: 0), + ], + ['name'], + ); + + $request = new ElicitRequest('Please provide your details', $schema); + $request = $request->withId(1); + + $json = json_encode($request); + $this->assertIsString($json); + + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + + $this->assertSame('2.0', $decoded['jsonrpc']); + $this->assertSame('elicitation/create', $decoded['method']); + $this->assertArrayHasKey('params', $decoded); + + $params = $decoded['params']; + $this->assertSame('Please provide your details', $params['message']); + $this->assertArrayHasKey('requestedSchema', $params); + + $requestedSchema = $params['requestedSchema']; + $this->assertSame('object', $requestedSchema['type']); + $this->assertArrayHasKey('name', $requestedSchema['properties']); + $this->assertArrayHasKey('age', $requestedSchema['properties']); + $this->assertSame(['name'], $requestedSchema['required']); + } +} diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php new file mode 100644 index 00000000..646aac34 --- /dev/null +++ b/tests/Unit/Schema/Result/ElicitResultTest.php @@ -0,0 +1,155 @@ + 'John', 'age' => 30]; + $result = new ElicitResult(ElicitAction::Accept, $content); + + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertSame($content, $result->content); + } + + public function testConstructorWithDecline(): void + { + $result = new ElicitResult(ElicitAction::Decline); + + $this->assertSame(ElicitAction::Decline, $result->action); + $this->assertNull($result->content); + } + + public function testConstructorWithCancel(): void + { + $result = new ElicitResult(ElicitAction::Cancel); + + $this->assertSame(ElicitAction::Cancel, $result->action); + $this->assertNull($result->content); + } + + public function testFromArrayWithAccept(): void + { + $result = ElicitResult::fromArray([ + 'action' => 'accept', + 'content' => ['name' => 'John', 'email' => 'john@example.com'], + ]); + + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertSame(['name' => 'John', 'email' => 'john@example.com'], $result->content); + } + + public function testFromArrayWithDecline(): void + { + $result = ElicitResult::fromArray([ + 'action' => 'decline', + ]); + + $this->assertSame(ElicitAction::Decline, $result->action); + $this->assertNull($result->content); + } + + public function testFromArrayWithCancel(): void + { + $result = ElicitResult::fromArray([ + 'action' => 'cancel', + ]); + + $this->assertSame(ElicitAction::Cancel, $result->action); + $this->assertNull($result->content); + } + + public function testFromArrayWithMissingAction(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "action"'); + + /* @phpstan-ignore argument.type */ + ElicitResult::fromArray([]); + } + + public function testFromArrayWithInvalidAction(): void + { + $this->expectException(\ValueError::class); + + ElicitResult::fromArray(['action' => 'invalid']); + } + + public function testIsAccepted(): void + { + $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); + $declineResult = new ElicitResult(ElicitAction::Decline); + $cancelResult = new ElicitResult(ElicitAction::Cancel); + + $this->assertTrue($acceptResult->isAccepted()); + $this->assertFalse($declineResult->isAccepted()); + $this->assertFalse($cancelResult->isAccepted()); + } + + public function testIsDeclined(): void + { + $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); + $declineResult = new ElicitResult(ElicitAction::Decline); + $cancelResult = new ElicitResult(ElicitAction::Cancel); + + $this->assertFalse($acceptResult->isDeclined()); + $this->assertTrue($declineResult->isDeclined()); + $this->assertFalse($cancelResult->isDeclined()); + } + + public function testIsCancelled(): void + { + $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); + $declineResult = new ElicitResult(ElicitAction::Decline); + $cancelResult = new ElicitResult(ElicitAction::Cancel); + + $this->assertFalse($acceptResult->isCancelled()); + $this->assertFalse($declineResult->isCancelled()); + $this->assertTrue($cancelResult->isCancelled()); + } + + public function testJsonSerializeWithAcceptAndContent(): void + { + $result = new ElicitResult(ElicitAction::Accept, ['name' => 'John', 'age' => 30]); + + $this->assertSame([ + 'action' => 'accept', + 'content' => ['name' => 'John', 'age' => 30], + ], $result->jsonSerialize()); + } + + public function testJsonSerializeWithDecline(): void + { + $result = new ElicitResult(ElicitAction::Decline); + + $this->assertSame([ + 'action' => 'decline', + ], $result->jsonSerialize()); + } + + public function testJsonSerializeWithCancel(): void + { + $result = new ElicitResult(ElicitAction::Cancel); + + $this->assertSame([ + 'action' => 'cancel', + ], $result->jsonSerialize()); + } +} diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php index 36c36f14..7ae7d942 100644 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -39,12 +39,15 @@ public function testHandleUsesConfigurationProtocolVersion(): void $handler = new InitializeHandler($configuration); $session = $this->createMock(SessionInterface::class); - $session->expects($this->once()) + $session->expects($this->exactly(2)) ->method('set') - ->with('client_info', [ - 'name' => 'client-app', - 'version' => '1.0.0', - ]); + ->willReturnCallback(function (string $key, array $value): void { + match ($key) { + 'client_info' => $this->assertSame(['name' => 'client-app', 'version' => '1.0.0'], $value), + 'client_capabilities' => $this->assertIsArray($value), + default => $this->fail("Unexpected session key: {$key}"), + }; + }); $request = InitializeRequest::fromArray([ 'jsonrpc' => MessageInterface::JSONRPC_VERSION,