diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f6000c..22ad2b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.9.0 ----- +* [BC Break] Remove the `providerClass` argument of `#[CompletionProvider]`. Use `provider:`, which takes the same class-string and is now the first positional argument. * Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. 0.8.0 diff --git a/src/Capability/Attribute/CompletionProvider.php b/src/Capability/Attribute/CompletionProvider.php index 9e8dc802..a21963c9 100644 --- a/src/Capability/Attribute/CompletionProvider.php +++ b/src/Capability/Attribute/CompletionProvider.php @@ -24,9 +24,9 @@ class CompletionProvider * @param class-string|ProviderInterface|null $provider if a class-string, it will be resolved * from the container at the point of use * @param ?array $values a list of values to use for completion + * @param class-string|null $enum an enum whose cases are the completions */ public function __construct( - public ?string $providerClass = null, public string|ProviderInterface|null $provider = null, public ?array $values = null, public ?string $enum = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 5b7e4765..c70559fc 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -325,8 +325,6 @@ private function getCompletionProviders(\ReflectionMethod $reflectionMethod): ar if ($attributeInstance->provider) { $completionProviders[$param->getName()] = $attributeInstance->provider; - } elseif ($attributeInstance->providerClass) { - $completionProviders[$param->getName()] = $attributeInstance->provider; } elseif ($attributeInstance->values) { $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); } elseif ($attributeInstance->enum) { diff --git a/src/Capability/Registry/Loader/ReflectedElementLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php index d1e849da..209644c7 100644 --- a/src/Capability/Registry/Loader/ReflectedElementLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -287,7 +287,7 @@ private function getHandlerDescription(\Closure|array|string $handler): string } /** - * @return array + * @return array|ProviderInterface> */ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { @@ -307,8 +307,6 @@ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $r if ($attributeInstance->provider) { $completionProviders[$param->getName()] = $attributeInstance->provider; - } elseif ($attributeInstance->providerClass) { - $completionProviders[$param->getName()] = $attributeInstance->providerClass; } elseif ($attributeInstance->values) { $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); } elseif ($attributeInstance->enum) { diff --git a/tests/Integration/CompletionTest.php b/tests/Integration/CompletionTest.php new file mode 100644 index 00000000..0da021a4 --- /dev/null +++ b/tests/Integration/CompletionTest.php @@ -0,0 +1,57 @@ +connect('completion'); + + $result = $client->complete(new PromptReference('book_seat'), ['name' => 'seat', 'value' => '12']); + + $this->assertSame(['12A', '12B'], $result->values); + } + + #[TestDox('an empty value offers every completion the provider knows')] + public function testEmptyValueOffersEveryCompletion(): void + { + $client = $this->connect('completion'); + + $result = $client->complete(new PromptReference('book_seat'), ['name' => 'seat', 'value' => '']); + + $this->assertSame(['12A', '12B', '14C'], $result->values); + } + + #[TestDox('an argument the prompt does not declare completes to nothing')] + public function testUnknownArgumentCompletesToNothing(): void + { + $client = $this->connect('completion'); + + $result = $client->complete(new PromptReference('book_seat'), ['name' => 'unknown', 'value' => '1']); + + $this->assertSame([], $result->values); + } +} diff --git a/tests/Integration/Fixture/Completion/BookingElements.php b/tests/Integration/Fixture/Completion/BookingElements.php new file mode 100644 index 00000000..2a64e5d4 --- /dev/null +++ b/tests/Integration/Fixture/Completion/BookingElements.php @@ -0,0 +1,38 @@ + + */ +final class BookingElements +{ + /** + * Confirms a seat booking. + * + * @param string $seat the seat to book + * + * @return array the prompt messages + */ + #[McpPrompt(name: 'book_seat')] + public function bookSeat( + #[CompletionProvider(provider: SeatCompletionProvider::class)] + string $seat, + ): array { + return [ + ['role' => 'user', 'content' => \sprintf('Book seat %s for me.', $seat)], + ]; + } +} diff --git a/tests/Integration/Fixture/Completion/SeatCompletionProvider.php b/tests/Integration/Fixture/Completion/SeatCompletionProvider.php new file mode 100644 index 00000000..a749fcf7 --- /dev/null +++ b/tests/Integration/Fixture/Completion/SeatCompletionProvider.php @@ -0,0 +1,42 @@ + + */ +final class SeatCompletionProvider implements ProviderInterface +{ + /** + * @param list $seats + */ + public function __construct( + private readonly array $seats, + ) { + } + + public function getCompletions(string $currentValue): array + { + return array_values(array_filter( + $this->seats, + static fn (string $seat): bool => str_starts_with($seat, $currentValue), + )); + } +} diff --git a/tests/Integration/Fixture/completion.php b/tests/Integration/Fixture/completion.php new file mode 100644 index 00000000..f33e9511 --- /dev/null +++ b/tests/Integration/Fixture/completion.php @@ -0,0 +1,31 @@ +set(SeatCompletionProvider::class, new SeatCompletionProvider(['12A', '12B', '14C'])); + +Server::builder() + ->setServerInfo('integration-server', '1.0.0') + ->setContainer($container) + ->setDiscovery(__DIR__, ['Completion']) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Unit/Capability/Attribute/CompletionProviderTest.php b/tests/Unit/Capability/Attribute/CompletionProviderTest.php index 19a78750..4b5269d3 100644 --- a/tests/Unit/Capability/Attribute/CompletionProviderTest.php +++ b/tests/Unit/Capability/Attribute/CompletionProviderTest.php @@ -26,6 +26,15 @@ public function testCanBeConstructedWithProviderClass(): void $this->assertNull($attribute->enum); } + public function testCanBeConstructedWithAPositionalProviderClass(): void + { + $attribute = new CompletionProvider(CompletionProviderFixture::class); + + $this->assertSame(CompletionProviderFixture::class, $attribute->provider); + $this->assertNull($attribute->values); + $this->assertNull($attribute->enum); + } + public function testCanBeConstructedWithProviderInstance(): void { $instance = new CompletionProviderFixture(); diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 5c716d7d..628aaa6a 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -94,7 +94,7 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler); $this->assertArrayHasKey('content_creator', $prompts); - $this->assertCount(3, $prompts['content_creator']->completionProviders); + $this->assertCount(4, $prompts['content_creator']->completionProviders); $templates = $discovery->getResourceTemplates(); $this->assertCount(4, $templates); @@ -165,7 +165,7 @@ public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttribu $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); - $this->assertCount(3, $prompts['content_creator']->completionProviders); + $this->assertCount(4, $prompts['content_creator']->completionProviders); $typeProvider = $prompts['content_creator']->completionProviders['type']; $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); @@ -182,4 +182,14 @@ public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttribu $categoryProvider = $templates['content://{category}/{slug}']->completionProviders['category']; $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); } + + public function testDiscoversPositionalCompletionProviderAsClassString(): void + { + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + + $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); + + // Kept as a class-string so the container resolves it at the point of use. + $this->assertEquals(CompletionProviderFixture::class, $prompts['content_creator']->completionProviders['author']); + } } diff --git a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php index 5ac67ddb..9975f9a6 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php @@ -14,13 +14,14 @@ use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; use Mcp\Capability\Attribute\McpResourceTemplate; +use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum; use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum; class EnhancedCompletionHandler { /** - * Create content with list and enum completion providers. + * Create content with list, enum and positional provider completion providers. */ #[McpPrompt(name: 'content_creator')] public function createContent( @@ -30,9 +31,11 @@ public function createContent( string $status, #[CompletionProvider(enum: PriorityEnum::class)] string $priority, + #[CompletionProvider(CompletionProviderFixture::class)] + string $author, ): array { return [ - ['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"], + ['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority} for {$author}"], ]; }