Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/Capability/Attribute/CompletionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,21 @@
class CompletionProvider
{
/**
* @param class-string<ProviderInterface>|ProviderInterface|null $provider if a class-string, it will be resolved
* from the container at the point of use
* @param ?array<int, int|float|string> $values a list of values to use for completion
* @param class-string<ProviderInterface>|null $providerClass a provider class, resolved from the
* container at the point of use
* @param class-string<ProviderInterface>|ProviderInterface|null $provider if a class-string, it will be resolved
* from the container at the point of use
* @param ?array<int, int|float|string> $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,
) {
if (1 !== \count(array_filter([$provider, $values, $enum]))) {
throw new InvalidArgumentException('Only one of provider, values, or enum can be set.');
if (1 !== \count(array_filter([$providerClass, $provider, $values, $enum]))) {
throw new InvalidArgumentException('Only one of providerClass, provider, values, or enum can be set.');
}
}
}
2 changes: 1 addition & 1 deletion src/Capability/Discovery/Discoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ private function getCompletionProviders(\ReflectionMethod $reflectionMethod): ar
if ($attributeInstance->provider) {
$completionProviders[$param->getName()] = $attributeInstance->provider;
} elseif ($attributeInstance->providerClass) {
$completionProviders[$param->getName()] = $attributeInstance->provider;
$completionProviders[$param->getName()] = $attributeInstance->providerClass;
} elseif ($attributeInstance->values) {
$completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
} elseif ($attributeInstance->enum) {
Expand Down
57 changes: 57 additions & 0 deletions tests/Integration/CompletionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Tests\Integration;

use Mcp\Schema\PromptReference;
use PHPUnit\Framework\Attributes\TestDox;

/**
* Argument completion driven by a `#[CompletionProvider(providerClass: …)]`.
*
* The fixture's provider only exists in the container, so completions coming
* back at all is what proves the attribute reached the registry and the
* container was asked to build it.
*
* @see Fixture/completion.php for the server under test
*/
final class CompletionTest extends IntegrationTestCase
{
#[TestDox('a providerClass argument completes from the container-built provider')]
public function testProviderClassCompletesFromTheContainer(): void
{
$client = $this->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 without a provider 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);
}
}
38 changes: 38 additions & 0 deletions tests/Integration/Fixture/Completion/BookingElements.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Tests\Integration\Fixture\Completion;

use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Capability\Attribute\McpPrompt;

/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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(providerClass: SeatCompletionProvider::class)]
string $seat,
): array {
return [
['role' => 'user', 'content' => \sprintf('Book seat %s for me.', $seat)],
];
}
}
42 changes: 42 additions & 0 deletions tests/Integration/Fixture/Completion/SeatCompletionProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Tests\Integration\Fixture\Completion;

use Mcp\Capability\Completion\ProviderInterface;

/**
* A provider that cannot be built without its seat map.
*
* The constructor takes a scalar the auto-wiring container cannot supply, so a
* completion that comes back with seats in it proves the provider was taken
* from the container rather than instantiated on the spot.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class SeatCompletionProvider implements ProviderInterface
{
/**
* @param list<string> $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),
));
}
}
31 changes: 31 additions & 0 deletions tests/Integration/Fixture/completion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

/*
* Server for {@see \Mcp\Tests\Integration\CompletionTest}.
*/

use Mcp\Capability\Registry\Container;
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
use Mcp\Tests\Integration\Fixture\Completion\SeatCompletionProvider;

require_once dirname(__DIR__, 3).'/vendor/autoload.php';

$container = new Container();
$container->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());
18 changes: 15 additions & 3 deletions tests/Unit/Capability/Attribute/CompletionProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ public function testCanBeConstructedWithProviderClass(): void
$attribute = new CompletionProvider(provider: CompletionProviderFixture::class);

$this->assertSame(CompletionProviderFixture::class, $attribute->provider);
$this->assertNull($attribute->providerClass);
$this->assertNull($attribute->values);
$this->assertNull($attribute->enum);
}

public function testCanBeConstructedWithProviderClassArgument(): void
{
$attribute = new CompletionProvider(providerClass: CompletionProviderFixture::class);

$this->assertSame(CompletionProviderFixture::class, $attribute->providerClass);
$this->assertNull($attribute->provider);
$this->assertNull($attribute->values);
$this->assertNull($attribute->enum);
}
Expand Down Expand Up @@ -58,14 +69,14 @@ public function testCanBeConstructedWithEnumClass(): void
public function testThrowsExceptionWhenNoParametersProvided(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only one of provider, values, or enum can be set');
$this->expectExceptionMessage('Only one of providerClass, provider, values, or enum can be set');
new CompletionProvider();
}

public function testThrowsExceptionWhenMultipleParametersProvided(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only one of provider, values, or enum can be set');
$this->expectExceptionMessage('Only one of providerClass, provider, values, or enum can be set');
new CompletionProvider(
provider: CompletionProviderFixture::class,
values: ['test']
Expand All @@ -75,8 +86,9 @@ public function testThrowsExceptionWhenMultipleParametersProvided(): void
public function testThrowsExceptionWhenAllParametersProvided(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only one of provider, values, or enum can be set');
$this->expectExceptionMessage('Only one of providerClass, provider, values, or enum can be set');
new CompletionProvider(
providerClass: CompletionProviderFixture::class,
provider: CompletionProviderFixture::class,
values: ['test'],
enum: StatusEnum::class
Expand Down