From fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Sun, 26 Apr 2026 15:37:40 +0200 Subject: [PATCH] [Schema][Server] Add top-level title to Tool (#288) * feat(schema): add top-level title to Tool Mirror PR #278's Prompt change for Tool: - Add optional ?string $title to Mcp\Schema\Tool between $name and $inputSchema. fromArray() now reads $data['title']; jsonSerialize() emits 'title' right after 'name'. The @phpstan-type ToolData gains title?: string. - Add ?string $title to the #[McpTool] attribute between $name and $description. - Note ToolAnnotations::$title as deprecated-for-display in favor of Tool::$title per MCP spec 2025-06-18 (PHPDoc only, field retained for BC). Phase 1 of plan 01--tool-title-spec-compliance. Later phases update Discoverer, Builder::addTool(), ArrayLoader, and the conformance fixture to match the new signature. * feat(server): thread title through discovery - Discoverer::processMethod() now propagates McpTool::$title into every discovered Tool via named arguments. - Builder::addTool() accepts ?string $title between $name and $description. [BC Break] positional callers passing the old $description as 3rd positional argument must switch to named arguments, matching the precedent set for addPrompt() in 0.5.0. - tests/Conformance/server.php migrated to named-argument form for every addTool() call. Phase 2 of plan 01--tool-title-spec-compliance. * feat(loader): forward title in ArrayLoader ArrayLoader now reads 'title' from each tool config entry and passes it into the Tool constructor, completing the wiring from Builder::addTool() through array-driven registration. The $tools @param array-shape typedef gains title: ?string between name and description. Phase 3 of plan 01--tool-title-spec-compliance. * test(schema): cover Tool.title end-to-end - New tests/Unit/Schema/ToolTest.php: constructor round-trip, jsonSerialize key-order when title set vs null, fromArray tolerance. - New tests/Unit/Capability/Discovery/DiscovererToolTitleTest plus a TitleFixture class verifying McpTool(title: ...) propagates through discovery into the registered Tool. - New tests/Unit/Capability/Registry/Loader/ArrayLoader... test verifying array-driven title propagation. - Extend McpToolTest for title named-arg and default-null. - Extend BuilderTest with addTool(title: 't') propagation. - Update tests/Unit/.../RegistryTest, CallToolHandlerTest, ListToolsHandlerTest and examples/.../server.php new Tool(...) call sites to pass title (named args) so they match the new constructor signature. - CHANGELOG 0.6.0 section: feature bullet + [BC Break] note for addTool() signature, mirroring 0.5.0 Prompt entries. - docs/server-builder.md: addTool() row and prose now list title? between name and description. Phase 4 of plan 01--tool-title-spec-compliance. All 730 phpunit tests pass; remaining phpstan warnings are pre-existing and unrelated to Tool.title. * refactor(schema): make Tool.title non-BC-breaking Move `title` to the end of the Tool, Builder::addTool, and McpTool attribute constructors with a default of null. This preserves positional-argument compatibility and removes the need for `title: null` boilerplate at call sites. JSON serialization order (name, title, inputSchema, ...) is unchanged. Also tighten the new tests: parameterize ToolTest with data providers, fold the Discoverer title fixture into the existing DiscoverableToolHandler, and drop the reflection-heavy Builder test that overlapped with ArrayLoaderToolTitleTest. * fix: move title after name --------- Co-authored-by: DDEV User --- CHANGELOG.md | 4 + docs/server-builder.md | 3 +- docs/server-client-communication.md | 2 +- .../ClientAwareService.php | 2 +- .../server/custom-method-handlers/server.php | 2 + .../elicitation/ElicitationHandlers.php | 6 +- src/Capability/Attribute/McpTool.php | 2 + src/Capability/Discovery/Discoverer.php | 15 +-- .../Registry/Loader/ArrayLoader.php | 2 + src/Schema/Tool.php | 30 +++--- src/Schema/ToolAnnotations.php | 2 +- src/Server/Builder.php | 4 + tests/Conformance/server.php | 24 ++--- .../Unit/Capability/Attribute/McpToolTest.php | 15 +++ .../Discovery/DiscovererToolTitleTest.php | 33 ++++++ .../Fixtures/DiscoverableToolHandler.php | 2 +- .../Loader/ArrayLoaderToolTitleTest.php | 48 +++++++++ tests/Unit/Capability/RegistryTest.php | 1 + tests/Unit/Schema/ToolTest.php | 100 ++++++++++++++++++ tests/Unit/Server/BuilderTest.php | 2 +- .../Handler/Request/CallToolHandlerTest.php | 4 +- .../Handler/Request/ListToolsHandlerTest.php | 1 + 22 files changed, 262 insertions(+), 42 deletions(-) create mode 100644 tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php create mode 100644 tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php create mode 100644 tests/Unit/Schema/ToolTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa1614..67ae392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591) * Add optional `title` field to `Prompt` and `McpPrompt` for MCP spec compliance * [BC Break] `Builder::addPrompt()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments. +* Add optional `title` field to `Tool` and `McpTool` for MCP spec compliance +* [BC Break] `Tool::__construct()` signature changed — `$title` parameter added between `$name` and `$inputSchema`. Callers using positional arguments must switch to named arguments or pass `null` for `$title`. +* [BC Break] `McpTool` attribute signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments. +* [BC Break] `Builder::addTool()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments. 0.4.0 ----- diff --git a/docs/server-builder.md b/docs/server-builder.md index 3ec22eb..1a8e466 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -278,6 +278,7 @@ $server = Server::builder() - `handler` (callable|string): The tool handler - `name` (string|null): Optional tool name +- `title` (string|null): Optional human-readable title for display in UI - `description` (string|null): Optional tool description - `annotations` (ToolAnnotations|null): Optional annotations for the tool - `inputSchema` (array|null): Optional input schema for the tool @@ -578,7 +579,7 @@ $server = Server::builder() | `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | | `addNotificationHandler()` | handler | Prepend a single custom notification handler | | `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | -| `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | +| `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | | `addPrompt()` | handler, name?, description? | Register prompt | diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index 5122d0b..b37ff63 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -24,7 +24,7 @@ use Mcp\Server\RequestContext; class MyService { - #[McpTool('my_tool', 'My Tool Description')] + #[McpTool(name: 'my_tool', description: 'My Tool Description')] public function myTool(RequestContext $context): string { $context->getClientGateway()->log(...); diff --git a/examples/server/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php index b33f4a7..2a3d834 100644 --- a/examples/server/client-communication/ClientAwareService.php +++ b/examples/server/client-communication/ClientAwareService.php @@ -28,7 +28,7 @@ final class ClientAwareService /** * @return array{incident: string, recommended_actions: string, model: string} */ - #[McpTool('coordinate_incident_response', 'Coordinate an incident response with logging, progress, and sampling.')] + #[McpTool(name: 'coordinate_incident_response', description: 'Coordinate an incident response with logging, progress, and sampling.')] public function coordinateIncident(RequestContext $context, string $incidentTitle): array { $clientGateway = $context->getClientGateway(); diff --git a/examples/server/custom-method-handlers/server.php b/examples/server/custom-method-handlers/server.php index 23bb6b7..461d9d9 100644 --- a/examples/server/custom-method-handlers/server.php +++ b/examples/server/custom-method-handlers/server.php @@ -25,6 +25,7 @@ logger()->info('Starting MCP Custom Method Handlers Server...'); $toolDefinitions = [ 'say_hello' => new Tool( name: 'say_hello', + title: null, inputSchema: [ 'type' => 'object', 'properties' => [ @@ -37,6 +38,7 @@ $toolDefinitions = [ ), 'sum' => new Tool( name: 'sum', + title: null, inputSchema: [ 'type' => 'object', 'properties' => [ diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php index 9105574..aaa1328 100644 --- a/examples/server/elicitation/ElicitationHandlers.php +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -45,7 +45,7 @@ final class ElicitationHandlers * * @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.')] + #[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')] public function bookRestaurant(RequestContext $context, string $restaurantName): array { if (!$context->getClientGateway()->supportsElicitation()) { @@ -156,7 +156,7 @@ final class ElicitationHandlers * * @return array{status: string, message: string} */ - #[McpTool('confirm_action', 'Request user confirmation before proceeding with an action.')] + #[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')] public function confirmAction(RequestContext $context, string $actionDescription): array { if (!$context->getClientGateway()->supportsElicitation()) { @@ -224,7 +224,7 @@ final class ElicitationHandlers * * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}} */ - #[McpTool('collect_feedback', 'Collect user feedback via elicitation form.')] + #[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')] public function collectFeedback(RequestContext $context, string $topic): array { if (!$context->getClientGateway()->supportsElicitation()) { diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index fe754e9..1c71e2c 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -22,6 +22,7 @@ class McpTool { /** * @param string|null $name The name of the tool (defaults to the method name) + * @param string|null $title Optional human-readable title for display in UI * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior * @param ?Icon[] $icons Optional list of icon URLs representing the tool @@ -30,6 +31,7 @@ class McpTool */ public function __construct( public ?string $name = null, + public ?string $title = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, public ?array $icons = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 586b711..2554a30 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -231,13 +231,14 @@ final class Discoverer implements DiscovererInterface $inputSchema = $this->schemaGenerator->generate($method); $outputSchema = $this->schemaGenerator->generateOutputSchema($method); $tool = new Tool( - $name, - $inputSchema, - $description, - $instance->annotations, - $instance->icons, - $instance->meta, - $outputSchema, + name: $name, + title: $instance->title, + inputSchema: $inputSchema, + description: $description, + annotations: $instance->annotations, + icons: $instance->icons, + meta: $instance->meta, + outputSchema: $outputSchema, ); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index d9a337e..34055a2 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -45,6 +45,7 @@ final class ArrayLoader implements LoaderInterface * @param array{ * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * annotations: ?ToolAnnotations, * icons: ?Icon[], @@ -115,6 +116,7 @@ final class ArrayLoader implements LoaderInterface $tool = new Tool( name: $name, + title: $data['title'] ?? null, inputSchema: $inputSchema, description: $description, annotations: $data['annotations'] ?? null, diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 6255701..1817804 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -33,6 +33,7 @@ use Mcp\Exception\InvalidArgumentException; * } * @phpstan-type ToolData array{ * name: string, + * title?: string, * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, @@ -47,10 +48,11 @@ class Tool implements \JsonSerializable { /** * @param string $name the name of the tool + * @param ?string $title Optional human-readable title for display in UI + * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool * @param ?string $description A human-readable description of the tool. * This can be used by clients to improve the LLM's understanding of * available tools. It can be thought of like a "hint" to the model. - * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool * @param ?ToolAnnotations $annotations optional additional tool information * @param ?Icon[] $icons optional icons representing the tool * @param ?array $meta Optional metadata @@ -58,6 +60,7 @@ class Tool implements \JsonSerializable */ public function __construct( public readonly string $name, + public readonly ?string $title, public readonly array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, @@ -95,19 +98,21 @@ class Tool implements \JsonSerializable } return new self( - $data['name'], - $inputSchema, - isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, - isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, - isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, - isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, - $outputSchema, + name: $data['name'], + title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, + inputSchema: $inputSchema, + description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, + annotations: isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + meta: isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + outputSchema: $outputSchema, ); } /** * @return array{ * name: string, + * title?: string, * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, @@ -118,10 +123,11 @@ class Tool implements \JsonSerializable */ public function jsonSerialize(): array { - $data = [ - 'name' => $this->name, - 'inputSchema' => $this->inputSchema, - ]; + $data = ['name' => $this->name]; + if (null !== $this->title) { + $data['title'] = $this->title; + } + $data['inputSchema'] = $this->inputSchema; if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/src/Schema/ToolAnnotations.php b/src/Schema/ToolAnnotations.php index 60e457f..783d959 100644 --- a/src/Schema/ToolAnnotations.php +++ b/src/Schema/ToolAnnotations.php @@ -28,7 +28,7 @@ namespace Mcp\Schema; class ToolAnnotations implements \JsonSerializable { /** - * @param ?string $title a human-readable title for the tool + * @param ?string $title a human-readable title for the tool — deprecated for display in favor of `Mcp\Schema\Tool::$title` per MCP spec revision 2025-06-18; retained for backward compatibility * @param ?bool $readOnlyHint if true, the tool does not modify its environment * @param ?bool $destructiveHint If true, the tool may perform destructive updates to its environment. If false, the tool performs only additive updates. * @param ?bool $idempotentHint If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment. (This property is meaningful only when `readOnlyHint == false`) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f1c5c05..953c4c2 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -99,6 +99,7 @@ final class Builder * @var array{ * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * annotations: ?ToolAnnotations, * icons: ?Icon[], @@ -373,6 +374,7 @@ final class Builder * Manually registers a tool handler. * * @param Handler $handler + * @param ?string $title Optional human-readable title for display in UI * @param array|null $inputSchema * @param ?Icon[] $icons * @param array|null $meta @@ -381,6 +383,7 @@ final class Builder public function addTool( callable|array|string $handler, ?string $name = null, + ?string $title = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null, @@ -391,6 +394,7 @@ final class Builder $this->tools[] = compact( 'handler', 'name', + 'title', 'description', 'annotations', 'inputSchema', diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 471d5d8..02c78c2 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -40,18 +40,18 @@ $server = Server::builder() ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger($logger) // Tools - ->addTool(static fn () => 'This is a simple text response for testing.', 'test_simple_text', 'Tests simple text content response') - ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), 'test_image_content', 'Tests image content response') - ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), 'test_audio_content', 'Tests audio content response') - ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), 'test_embedded_resource', 'Tests embedded resource content response') - ->addTool([Elements::class, 'toolMultipleTypes'], 'test_multiple_content_types', 'Tests response with multiple content types') - ->addTool([Elements::class, 'toolWithLogging'], 'test_tool_with_logging', 'Tests tool that emits log messages') - ->addTool([Elements::class, 'toolWithProgress'], 'test_tool_with_progress', 'Tests tool that reports progress notifications') - ->addTool([Elements::class, 'toolWithSampling'], 'test_sampling', 'Tests server-initiated sampling') - ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), 'test_error_handling', 'Tests error response handling') - ->addTool([Elements::class, 'toolWithElicitation'], 'test_elicitation', 'Tests server-initiated elicitation') - ->addTool([Elements::class, 'toolWithElicitationDefaults'], 'test_elicitation_sep1034_defaults', 'Tests elicitation with default values') - ->addTool([Elements::class, 'toolWithElicitationEnums'], 'test_elicitation_sep1330_enums', 'Tests elicitation with enum schemas') + ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') + ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') + ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') + ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') + ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') + ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') + ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') + ->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling') + ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') + ->addTool([Elements::class, 'toolWithElicitation'], name: 'test_elicitation', description: 'Tests server-initiated elicitation') + ->addTool([Elements::class, 'toolWithElicitationDefaults'], name: 'test_elicitation_sep1034_defaults', description: 'Tests elicitation with default values') + ->addTool([Elements::class, 'toolWithElicitationEnums'], name: 'test_elicitation_sep1330_enums', description: 'Tests elicitation with enum schemas') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') diff --git a/tests/Unit/Capability/Attribute/McpToolTest.php b/tests/Unit/Capability/Attribute/McpToolTest.php index b6ab86d..e631458 100644 --- a/tests/Unit/Capability/Attribute/McpToolTest.php +++ b/tests/Unit/Capability/Attribute/McpToolTest.php @@ -52,6 +52,21 @@ class McpToolTest extends TestCase $this->assertNull($attribute->outputSchema); } + public function testInstantiatesWithTitle(): void + { + $attribute = new McpTool(name: 'n', title: 'T'); + + $this->assertSame('n', $attribute->name); + $this->assertSame('T', $attribute->title); + } + + public function testDefaultTitleIsNull(): void + { + $attribute = new McpTool(); + + $this->assertNull($attribute->title); + } + public function testInstantiatesWithOutputSchema(): void { // Arrange diff --git a/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php b/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php new file mode 100644 index 0000000..9375425 --- /dev/null +++ b/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php @@ -0,0 +1,33 @@ +discover(__DIR__, ['Fixtures']); + + $tools = $discovery->getTools(); + + $this->assertArrayHasKey('greet_user', $tools); + $toolRef = $tools['greet_user']; + $this->assertInstanceOf(ToolReference::class, $toolRef); + $this->assertSame('Greet User', $toolRef->tool->title); + } +} diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php index 1c6bc39..f20cda5 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php @@ -24,7 +24,7 @@ class DiscoverableToolHandler * * @return string the greeting */ - #[McpTool(name: 'greet_user', description: 'Greets a user by name.')] + #[McpTool(name: 'greet_user', description: 'Greets a user by name.', title: 'Greet User')] public function greet(string $name): string { return "Hello, {$name}!"; diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php b/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php new file mode 100644 index 0000000..68a1bfb --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php @@ -0,0 +1,48 @@ + static fn (): string => 'ok', + 'name' => 'weather_lookup', + 'title' => 'Weather Lookup', + 'description' => null, + 'annotations' => null, + 'inputSchema' => [ + 'type' => 'object', + 'properties' => new \stdClass(), + 'required' => null, + ], + 'icons' => null, + 'meta' => null, + 'outputSchema' => null, + ], + ]; + + $loader = new ArrayLoader($tools); + $registry = new Registry(); + + $loader->load($registry); + + $toolRef = $registry->getTool('weather_lookup'); + $this->assertSame('Weather Lookup', $toolRef->tool->title); + } +} diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index e8b1958..90ac887 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -615,6 +615,7 @@ class RegistryTest extends TestCase { return new Tool( name: $name, + title: null, inputSchema: [ 'type' => 'object', 'properties' => [ diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php new file mode 100644 index 0000000..dd6861c --- /dev/null +++ b/tests/Unit/Schema/ToolTest.php @@ -0,0 +1,100 @@ +, required: string[]|null} + */ + private static function validInputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['q' => ['type' => 'string']], + 'required' => null, + ]; + } + + private static function makeTool(?string $title, ?string $description = null): Tool + { + return new Tool( + name: 'x', + inputSchema: self::validInputSchema(), + description: $description, + annotations: null, + title: $title, + ); + } + + /** + * @return iterable}> + */ + public static function serializationKeyOrderProvider(): iterable + { + yield 'with title' => ['Friendly Title', ['name', 'title', 'inputSchema']]; + yield 'without title' => [null, ['name', 'inputSchema']]; + } + + /** + * @param list $expectedKeys + */ + #[DataProvider('serializationKeyOrderProvider')] + public function testSerializationPlacesTitleBetweenNameAndInputSchema(?string $title, array $expectedKeys): void + { + $serialized = self::makeTool($title)->jsonSerialize(); + + $this->assertSame($expectedKeys, array_keys($serialized)); + if (null !== $title) { + $this->assertSame($title, $serialized['title']); + } else { + $this->assertArrayNotHasKey('title', $serialized); + } + } + + /** + * @return iterable, ?string}> + */ + public static function fromArrayTitleProvider(): iterable + { + yield 'title present' => [['title' => 'Friendly Title'], 'Friendly Title']; + yield 'title missing' => [[], null]; + } + + /** + * @param array $extra + */ + #[DataProvider('fromArrayTitleProvider')] + public function testFromArrayReadsTitle(array $extra, ?string $expectedTitle): void + { + $tool = Tool::fromArray(['name' => 'x', 'inputSchema' => self::validInputSchema()] + $extra); + + $this->assertSame($expectedTitle, $tool->title); + } + + public function testRoundTripPreservesTitle(): void + { + $original = self::makeTool('Friendly Title', 'desc'); + + /** @var array{name: string, title?: string, inputSchema: array{type: 'object', properties: array, required: string[]|null}, description?: string|null} $serialized */ + $serialized = $original->jsonSerialize(); + $restored = Tool::fromArray($serialized); + + $this->assertSame('Friendly Title', $restored->title); + $this->assertSame($original->name, $restored->name); + $this->assertSame($original->description, $restored->description); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index e8e54af..8a92d59 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -71,7 +71,7 @@ final class BuilderTest extends TestCase $server = Server::builder() ->setServerInfo('test', '1.0.0') ->setReferenceHandler($referenceHandler) - ->addTool(static fn (): string => 'original', 'test_tool', 'A test tool') + ->addTool(static fn (): string => 'original', name: 'test_tool', description: 'A test tool') ->build(); $result = $this->callTool($server, 'test_tool'); diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index b8ec53e..cad7af1 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -483,7 +483,7 @@ class CallToolHandlerTest extends TestCase $request = $this->createCallToolRequest('result_tool', ['query' => 'php']); $toolReference = $this->getMockBuilder(ToolReference::class) - ->setConstructorArgs([new Tool('simple_tool', $schema, null, null), static function () {}]) + ->setConstructorArgs([new Tool('simple_tool', null, $schema, null, null), static function () {}]) ->getMock(); $this->registry @@ -535,7 +535,7 @@ class CallToolHandlerTest extends TestCase ], 'required' => [], ]; - $tool = new Tool($name, $schema, null, null, null, null, $outputSchema); + $tool = new Tool($name, null, $schema, null, null, null, null, $outputSchema); $builder = $this->getMockBuilder(ToolReference::class) ->setConstructorArgs([$tool, $handler]); diff --git a/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php index 2453630..b97185a 100644 --- a/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php @@ -289,6 +289,7 @@ class ListToolsHandlerTest extends TestCase for ($i = 0; $i < $count; ++$i) { $tool = new Tool( name: "tool_$i", + title: null, inputSchema: [ 'type' => 'object', 'properties' => [],