Skip to content
Merged
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
139 changes: 126 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,11 @@ Completion providers enable MCP clients to offer auto-completion suggestions in

> **Note**: Tools and resources can be discovered via standard MCP commands (`tools/list`, `resources/list`), so completion providers are not needed for them. Completion providers are used only for resource templates (URI variables) and prompt arguments.
Completion providers must implement the `CompletionProviderInterface`:
The `#[CompletionProvider]` attribute supports three types of completion sources:

#### 1. Custom Provider Classes

For complex completion logic, implement the `CompletionProviderInterface`:

```php
use PhpMcp\Server\Contracts\CompletionProviderInterface;
Expand All @@ -766,34 +770,143 @@ use PhpMcp\Server\Attributes\{McpResourceTemplate, CompletionProvider};

class UserIdCompletionProvider implements CompletionProviderInterface
{
public function __construct(private DatabaseService $db) {}

public function getCompletions(string $currentValue, SessionInterface $session): array
{
// Return completion suggestions based on current input
$allUsers = ['user_1', 'user_2', 'user_3', 'admin_user'];

// Filter based on what user has typed so far
return array_filter($allUsers, fn($user) => str_starts_with($user, $currentValue));
// Dynamic completion from database
return $this->db->searchUsers($currentValue);
}
}

class UserService
{
#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')]
public function getUserProfile(
#[CompletionProvider(UserIdCompletionProvider::class)]
#[CompletionProvider(provider: UserIdCompletionProvider::class)] // Class string - resolved from container
string $userId
): array {
// Always validate input even with completion providers
// Users can still pass any value regardless of completion suggestions
if (!$this->isValidUserId($userId)) {
throw new \InvalidArgumentException('Invalid user ID provided');
}

return ['id' => $userId, 'name' => 'John Doe'];
}
}
```

You can also pass pre-configured provider instances:

```php
class DocumentService
{
#[McpPrompt(name: 'document_prompt')]
public function generatePrompt(
#[CompletionProvider(provider: new UserIdCompletionProvider($database))] // Pre-configured instance
string $userId,

#[CompletionProvider(provider: $this->categoryProvider)] // Instance from property
string $category
): array {
return [['role' => 'user', 'content' => "Generate document for user {$userId} in {$category}"]];
}
}
```

#### 2. Simple List Completions

For static completion lists, use the `values` parameter:

```php
use PhpMcp\Server\Attributes\{McpPrompt, CompletionProvider};

class ContentService
{
#[McpPrompt(name: 'content_generator')]
public function generateContent(
#[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide', 'documentation'])]
string $contentType,

#[CompletionProvider(values: ['beginner', 'intermediate', 'advanced', 'expert'])]
string $difficulty
): array {
return [['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"]];
}
}
```

#### 3. Enum-Based Completions

For enum classes, use the `enum` parameter:

```php
enum Priority: string
{
case LOW = 'low';
case MEDIUM = 'medium';
case HIGH = 'high';
case CRITICAL = 'critical';
}

enum Status // Unit enum (no backing values)
{
case DRAFT;
case PUBLISHED;
case ARCHIVED;
}

class TaskService
{
#[McpTool(name: 'create_task')]
public function createTask(
string $title,

#[CompletionProvider(enum: Priority::class)] // String-backed enum uses values
string $priority,

#[CompletionProvider(enum: Status::class)] // Unit enum uses case names
string $status
): array {
return ['id' => 123, 'title' => $title, 'priority' => $priority, 'status' => $status];
}
}
```

#### Manual Registration with Completion Providers

```php
$server = Server::make()
->withServerInfo('Completion Demo', '1.0.0')

// Using provider class (resolved from container)
->withPrompt(
[DocumentHandler::class, 'generateReport'],
name: 'document_report'
// Completion providers are auto-discovered from method attributes
)

// Using closure with inline completion providers
->withPrompt(
function(
#[CompletionProvider(values: ['json', 'xml', 'csv', 'yaml'])]
string $format,

#[CompletionProvider(enum: Priority::class)]
string $priority
): array {
return [['role' => 'user', 'content' => "Export data in {$format} format with {$priority} priority"]];
},
name: 'export_data'
)

->build();
```

#### Completion Provider Resolution

The server automatically handles provider resolution:

- **Class strings** (`MyProvider::class`) → Resolved from PSR-11 container with dependency injection
- **Instances** (`new MyProvider()`) → Used directly as-is
- **Values arrays** (`['a', 'b', 'c']`) → Automatically wrapped in `ListCompletionProvider`
- **Enum classes** (`MyEnum::class`) → Automatically wrapped in `EnumCompletionProvider`

> **Important**: Completion providers only offer suggestions to users in the MCP client interface. Users can still input any value, so always validate parameters in your handlers regardless of completion provider constraints.
### Custom Dependency Injection
Expand Down
4 changes: 2 additions & 2 deletions examples/02-discovery-http-userprofile/McpElements.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function __construct(LoggerInterface $logger)
)]

public function getUserProfile(
#[CompletionProvider(providerClass: UserIdCompletionProvider::class)]
#[CompletionProvider(values: ['101', '102', '103'])]
string $userId
): array {
$this->logger->info('Reading resource: user profile', ['userId' => $userId]);
Expand Down Expand Up @@ -116,7 +116,7 @@ public function testToolWithoutParams()
*/
#[McpPrompt(name: 'generate_bio_prompt')]
public function generateBio(
#[CompletionProvider(providerClass: UserIdCompletionProvider::class)]
#[CompletionProvider(provider: UserIdCompletionProvider::class)]
string $userId,
string $tone = 'professional'
): array {
Expand Down
12 changes: 9 additions & 3 deletions src/Attributes/CompletionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@
class CompletionProvider
{
/**
* @param class-string<CompletionProviderInterface> $providerClass FQCN of the completion provider class.
* @param class-string<CompletionProviderInterface>|CompletionProviderInterface|null $provider If a class-string, it will be resolved from the container at the point of use.
*/
public function __construct(public string $providerClass)
{
public function __construct(
public string|CompletionProviderInterface|null $provider = null,
public ?array $values = null,
public ?string $enum = null,
) {
if (count(array_filter([$provider, $values, $enum])) !== 1) {
throw new \InvalidArgumentException('Only one of provider, values, or enum can be set');
}
}
}
37 changes: 37 additions & 0 deletions src/Defaults/EnumCompletionProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Defaults;

use PhpMcp\Server\Contracts\CompletionProviderInterface;
use PhpMcp\Server\Contracts\SessionInterface;

class EnumCompletionProvider implements CompletionProviderInterface
{
private array $values;

public function __construct(string $enumClass)
{
if (!enum_exists($enumClass)) {
throw new \InvalidArgumentException("Class {$enumClass} is not an enum");
}

$this->values = array_map(
fn($case) => isset($case->value) && is_string($case->value) ? $case->value : $case->name,
$enumClass::cases()
);
}

public function getCompletions(string $currentValue, SessionInterface $session): array
{
if (empty($currentValue)) {
return $this->values;
}

return array_values(array_filter(
$this->values,
fn(string $value) => str_starts_with($value, $currentValue)
));
}
}
25 changes: 25 additions & 0 deletions src/Defaults/ListCompletionProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Defaults;

use PhpMcp\Server\Contracts\CompletionProviderInterface;
use PhpMcp\Server\Contracts\SessionInterface;

class ListCompletionProvider implements CompletionProviderInterface
{
public function __construct(private array $values) {}

public function getCompletions(string $currentValue, SessionInterface $session): array
{
if (empty($currentValue)) {
return $this->values;
}

return array_values(array_filter(
$this->values,
fn(string $value) => str_starts_with($value, $currentValue)
));
}
}
20 changes: 2 additions & 18 deletions src/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ public function handleCompletionComplete(CompletionCompleteRequest $request, Ses
throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'.");
}

$providerClass = $registeredPrompt->getCompletionProvider($argumentName);
return $registeredPrompt->complete($this->container, $argumentName, $currentValue, $session);
} elseif ($ref->type === 'ref/resource') {
$identifier = $ref->uri;
$registeredResourceTemplate = $this->registry->getResourceTemplate($identifier);
Expand All @@ -360,26 +360,10 @@ public function handleCompletionComplete(CompletionCompleteRequest $request, Ses
throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'.");
}

$providerClass = $registeredResourceTemplate->getCompletionProvider($argumentName);
return $registeredResourceTemplate->complete($this->container, $argumentName, $currentValue, $session);
} else {
throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request.");
}

if (! $providerClass) {
$this->logger->warning("No completion provider found for argument '{$argumentName}' in '{$ref->type}' '{$identifier}'.");
return new CompletionCompleteResult([]);
}

$provider = $this->container->get($providerClass);

$completions = $provider->getCompletions($currentValue, $session);

$total = count($completions);
$hasMore = $total > 100;

$pagedCompletions = array_slice($completions, 0, 100);

return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
}

public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult
Expand Down
43 changes: 39 additions & 4 deletions src/Elements/RegisteredPrompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
use PhpMcp\Schema\Content\TextContent;
use PhpMcp\Schema\Content\TextResourceContents;
use PhpMcp\Schema\Enum\Role;
use PhpMcp\Schema\Result\CompletionCompleteResult;
use PhpMcp\Server\Contracts\CompletionProviderInterface;
use PhpMcp\Server\Contracts\SessionInterface;
use Psr\Container\ContainerInterface;
use Throwable;

Expand Down Expand Up @@ -47,9 +50,31 @@ public function get(ContainerInterface $container, array $arguments): array
return $this->formatResult($result);
}

public function getCompletionProvider(string $argumentName): ?string
public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult
{
return $this->completionProviders[$argumentName] ?? null;
$providerClassOrInstance = $this->completionProviders[$argument] ?? null;
if ($providerClassOrInstance === null) {
return new CompletionCompleteResult([]);
}

if (is_string($providerClassOrInstance)) {
if (! class_exists($providerClassOrInstance)) {
throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist.");
}

$provider = $container->get($providerClassOrInstance);
} else {
$provider = $providerClassOrInstance;
}

$completions = $provider->getCompletions($value, $session);

$total = count($completions);
$hasMore = $total > 100;

$pagedCompletions = array_slice($completions, 0, 100);

return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
}

/**
Expand Down Expand Up @@ -268,9 +293,14 @@ private function formatResourceContent(array $content, string $indexStr): Embedd

public function toArray(): array
{
$completionProviders = [];
foreach ($this->completionProviders as $argument => $provider) {
$completionProviders[$argument] = serialize($provider);
}

return [
'schema' => $this->schema->toArray(),
'completionProviders' => $this->completionProviders,
'completionProviders' => $completionProviders,
...parent::toArray(),
];
}
Expand All @@ -282,11 +312,16 @@ public static function fromArray(array $data): self|false
return false;
}

$completionProviders = [];
foreach ($data['completionProviders'] ?? [] as $argument => $provider) {
$completionProviders[$argument] = unserialize($provider);
}

return new self(
Prompt::fromArray($data['schema']),
$data['handler'],
$data['isManual'] ?? false,
$data['completionProviders'] ?? [],
$completionProviders,
);
} catch (Throwable $e) {
return false;
Expand Down
Loading