Skip to content

Commit a6fbb96

Browse files
feat: Implement argument completion provider registration for MCP elements
- New `CompletionProviderInterface` and `CompletionProvider` attribute. - Updated the Dispatcher to handle completion requests, integrating completion logic for prompts and resource templates. - Enhanced the Registry to manage completion providers, allowing for better organization and retrieval of completion logic. - Introduced methods in ServerBuilder and Discoverer to facilitate automatic registration of completion providers during element discovery and server setup.
1 parent f49ff78 commit a6fbb96

File tree

11 files changed

+282
-33
lines changed

11 files changed

+282
-33
lines changed

examples/02-discovery-http-userprofile/McpElements.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Mcp\HttpUserProfileExample;
44

5+
use PhpMcp\Server\Attributes\CompletionProvider;
56
use PhpMcp\Server\Attributes\McpPrompt;
67
use PhpMcp\Server\Attributes\McpResource;
78
use PhpMcp\Server\Attributes\McpResourceTemplate;
@@ -40,8 +41,11 @@ public function __construct(LoggerInterface $logger)
4041
description: 'Get profile information for a specific user ID.',
4142
mimeType: 'application/json'
4243
)]
43-
public function getUserProfile(string $userId): array
44-
{
44+
45+
public function getUserProfile(
46+
#[CompletionProvider(providerClass: UserIdCompletionProvider::class)]
47+
string $userId
48+
): array {
4549
$this->logger->info('Reading resource: user profile', ['userId' => $userId]);
4650
if (! isset($this->users[$userId])) {
4751
// Throwing an exception that Processor can turn into an error response
@@ -87,7 +91,7 @@ public function sendWelcomeMessage(string $userId, ?string $customMessage = null
8791
$user = $this->users[$userId];
8892
$message = "Welcome, {$user['name']}!";
8993
if ($customMessage) {
90-
$message .= ' '.$customMessage;
94+
$message .= ' ' . $customMessage;
9195
}
9296
// Simulate sending
9397
$this->logger->info("Simulated sending message to {$user['email']}: {$message}");
@@ -105,8 +109,11 @@ public function sendWelcomeMessage(string $userId, ?string $customMessage = null
105109
* @throws McpServerException If user not found.
106110
*/
107111
#[McpPrompt(name: 'generate_bio_prompt')]
108-
public function generateBio(string $userId, string $tone = 'professional'): array
109-
{
112+
public function generateBio(
113+
#[CompletionProvider(providerClass: UserIdCompletionProvider::class)]
114+
string $userId,
115+
string $tone = 'professional'
116+
): array {
110117
$this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]);
111118
if (! isset($this->users[$userId])) {
112119
throw McpServerException::invalidParams("User not found for bio prompt: {$userId}");
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mcp\HttpUserProfileExample;
6+
7+
use PhpMcp\Server\Contracts\CompletionProviderInterface;
8+
use PhpMcp\Server\Contracts\SessionInterface;
9+
10+
class UserIdCompletionProvider implements CompletionProviderInterface
11+
{
12+
public function getCompletions(string $currentValue, SessionInterface $session): array
13+
{
14+
$availableUserIds = ['101', '102', '103'];
15+
$filteredUserIds = array_filter($availableUserIds, fn(string $userId) => str_contains($userId, $currentValue));
16+
17+
return $filteredUserIds;
18+
}
19+
}

examples/02-discovery-http-userprofile/server.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939
chdir(__DIR__);
4040
require_once '../../vendor/autoload.php';
4141
require_once 'McpElements.php';
42+
require_once 'UserIdCompletionProvider.php';
4243

44+
use PhpMcp\Schema\ServerCapabilities;
4345
use PhpMcp\Server\Defaults\BasicContainer;
4446
use PhpMcp\Server\Server;
4547
use PhpMcp\Server\Transports\HttpServerTransport;
@@ -65,6 +67,7 @@ public function log($level, \Stringable|string $message, array $context = []): v
6567

6668
$server = Server::make()
6769
->withServerInfo('HTTP User Profiles', '1.0.0')
70+
->withCapabilities(ServerCapabilities::make(completionsEnabled: true))
6871
->withLogger($logger)
6972
->withContainer($container)
7073
->build();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Attributes;
6+
7+
use Attribute;
8+
use PhpMcp\Server\Contracts\CompletionProviderInterface;
9+
10+
#[Attribute(Attribute::TARGET_PARAMETER)]
11+
class CompletionProvider
12+
{
13+
/**
14+
* @param class-string<CompletionProviderInterface> $providerClass FQCN of the completion provider class.
15+
*/
16+
public function __construct(public string $providerClass) {}
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMcp\Server\Contracts;
6+
7+
interface CompletionProviderInterface
8+
{
9+
/**
10+
* Get completions for a given current value.
11+
*
12+
* @param string $currentValue The current value to get completions for.
13+
* @param SessionInterface $session The session to get completions for.
14+
* @return array The completions.
15+
*/
16+
public function getCompletions(string $currentValue, SessionInterface $session): array;
17+
}

src/Dispatcher.php

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -325,26 +325,66 @@ public function handleLoggingSetLevel(SetLogLevelRequest $request, SessionInterf
325325
public function handleCompletionComplete(CompletionCompleteRequest $request, SessionInterface $session): CompletionCompleteResult
326326
{
327327
$ref = $request->ref;
328-
$argument = $request->argument;
329-
330-
$completionValues = [];
331-
$total = null;
332-
$hasMore = null;
333-
334-
// TODO: Implement actual completion logic here.
335-
// This requires a way to:
336-
// 1. Find the target prompt or resource template definition.
337-
// 2. Determine if that definition has a completion provider for the given $argName.
338-
// 3. Invoke that provider with $currentValue and $session (for context).
339-
340-
// --- Example Logic ---
341-
if ($argument['name'] === 'userId') {
342-
$completionValues = ['101', '102', '103'];
343-
$total = 3;
328+
$argumentName = $request->argument['name'];
329+
$currentValue = $request->argument['value'];
330+
331+
$identifier = null;
332+
333+
if ($ref->type === 'ref/prompt') {
334+
$identifier = $ref->name;
335+
['prompt' => $prompt] = $this->registry->getPrompt($identifier);
336+
if (! $prompt) {
337+
throw McpServerException::invalidParams("Prompt '{$identifier}' not found.");
338+
}
339+
340+
$foundArg = false;
341+
foreach ($prompt->arguments as $arg) {
342+
if ($arg->name === $argumentName) {
343+
$foundArg = true;
344+
break;
345+
}
346+
}
347+
if (! $foundArg) {
348+
throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'.");
349+
}
350+
} else if ($ref->type === 'ref/resource') {
351+
$identifier = $ref->uri;
352+
['resourceTemplate' => $resourceTemplate, 'variables' => $uriVariables] = $this->registry->getResourceTemplate($identifier);
353+
if (! $resourceTemplate) {
354+
throw McpServerException::invalidParams("Resource template '{$identifier}' not found.");
355+
}
356+
357+
$foundArg = false;
358+
foreach ($uriVariables as $uriVariableName) {
359+
if ($uriVariableName === $argumentName) {
360+
$foundArg = true;
361+
break;
362+
}
363+
}
364+
365+
if (! $foundArg) {
366+
throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'.");
367+
}
368+
} else {
369+
throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request.");
370+
}
371+
372+
$providerClass = $this->registry->getCompletionProvider($ref->type, $identifier, $argumentName);
373+
if (! $providerClass) {
374+
$this->logger->warning("No completion provider found for argument '{$argumentName}' in '{$ref->type}' '{$identifier}'.");
375+
return new CompletionCompleteResult([]);
344376
}
345-
// --- End Example ---
346377

347-
return new CompletionCompleteResult($completionValues, $total, $hasMore);
378+
$provider = $this->container->get($providerClass);
379+
380+
$completions = $provider->getCompletions($currentValue, $session);
381+
382+
$total = count($completions);
383+
$hasMore = $total > 100;
384+
385+
$pagedCompletions = array_slice($completions, 0, 100);
386+
387+
return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
348388
}
349389

350390
public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult

src/Protocol.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ public function processMessage(Request|Notification|BatchRequest $message, strin
119119

120120
$session = $this->sessionManager->getSession($sessionId);
121121

122+
if ($session === null) {
123+
$error = Error::forInvalidRequest('Invalid or expired session. Please re-initialize the session.', $message->id);
124+
$this->transport->sendMessage($error, $sessionId, $context);
125+
return;
126+
}
127+
122128
$response = null;
123129

124130
if ($message instanceof BatchRequest) {

src/Registry.php

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PhpMcp\Schema\Resource;
1111
use PhpMcp\Schema\ResourceTemplate;
1212
use PhpMcp\Schema\Tool;
13+
use PhpMcp\Server\Contracts\CompletionProviderInterface;
1314
use PhpMcp\Server\Exception\DefinitionException;
1415
use PhpMcp\Server\Support\Handler;
1516
use PhpMcp\Server\Support\UriTemplateMatcher;
@@ -55,6 +56,30 @@ class Registry implements EventEmitterInterface
5556
'prompts' => '',
5657
];
5758

59+
/**
60+
* Stores completion providers.
61+
* Structure:
62+
* [
63+
* 'ref/prompt' => [ // Ref Type
64+
* 'prompt_name_1' => [ // Element Name/URI
65+
* 'argument_name_A' => 'ProviderClassFQCN_For_Prompt1_ArgA',
66+
* 'argument_name_B' => 'ProviderClassFQCN_For_Prompt1_ArgB',
67+
* ],
68+
* 'prompt_name_2' => [ //... ],
69+
* ],
70+
* 'ref/resource' => [ // Ref Type (for URI templates)
71+
* 'resource_template_uri_1' => [ // Element URI Template
72+
* 'uri_variable_name_X' => 'ProviderClassFQCN_For_Template1_VarX',
73+
* ],
74+
* ],
75+
* ]
76+
* @var array<string, array<string, array<string, class-string<ArgumentCompletionProviderInterface>>>>
77+
*/
78+
private array $completionProviders = [
79+
'ref/prompt' => [],
80+
'ref/resource' => [],
81+
];
82+
5883
private bool $notificationsEnabled = true;
5984

6085
public function __construct(
@@ -307,6 +332,23 @@ public function registerPrompt(Prompt $prompt, Handler $handler, bool $isManual
307332
$this->checkAndEmitChange('prompts', $this->prompts);
308333
}
309334

335+
/**
336+
* @param 'ref/prompt'|'ref/resource' $refType
337+
* @param string $identifier Name for prompts, URI template for resource templates
338+
* @param string $argument The argument name to register the completion provider for.
339+
* @param class-string<CompletionProviderInterface> $providerClass
340+
*/
341+
public function registerCompletionProvider(string $refType, string $identifier, string $argument, string $providerClass): void
342+
{
343+
if (!in_array($refType, ['ref/prompt', 'ref/resource'])) {
344+
$this->logger->warning("Invalid refType '{$refType}' for completion provider registration.");
345+
return;
346+
}
347+
348+
$this->completionProviders[$refType][$identifier][$argument] = $providerClass;
349+
$this->logger->debug("Registered completion provider for {$refType} '{$identifier}', argument '{$argument}'", ['provider' => $providerClass]);
350+
}
351+
310352
public function enableNotifications(): void
311353
{
312354
$this->notificationsEnabled = true;
@@ -505,6 +547,36 @@ public function getResource(string $uri, bool $includeTemplates = true): ?array
505547
return null;
506548
}
507549

550+
/** @return array{
551+
* resourceTemplate: ResourceTemplate,
552+
* handler: Handler,
553+
* variables: array<string, string>,
554+
* }|null */
555+
public function getResourceTemplate(string $uriTemplate): ?array
556+
{
557+
$registration = $this->resourceTemplates[$uriTemplate] ?? null;
558+
if (!$registration) {
559+
return null;
560+
}
561+
562+
try {
563+
$matcher = new UriTemplateMatcher($uriTemplate);
564+
$variables = $matcher->getVariables();
565+
} catch (\InvalidArgumentException $e) {
566+
$this->logger->warning('Invalid resource template encountered during matching', [
567+
'template' => $registration['resourceTemplate']->uriTemplate,
568+
'error' => $e->getMessage(),
569+
]);
570+
return null;
571+
}
572+
573+
return [
574+
'resourceTemplate' => $registration['resourceTemplate'],
575+
'handler' => $registration['handler'],
576+
'variables' => $variables,
577+
];
578+
}
579+
508580
/** @return array{prompt: Prompt, handler: Handler}|null */
509581
public function getPrompt(string $name): ?array
510582
{
@@ -514,24 +586,35 @@ public function getPrompt(string $name): ?array
514586
/** @return array<string, Tool> */
515587
public function getTools(): array
516588
{
517-
return array_map(fn ($registration) => $registration['tool'], $this->tools);
589+
return array_map(fn($registration) => $registration['tool'], $this->tools);
518590
}
519591

520592
/** @return array<string, Resource> */
521593
public function getResources(): array
522594
{
523-
return array_map(fn ($registration) => $registration['resource'], $this->resources);
595+
return array_map(fn($registration) => $registration['resource'], $this->resources);
524596
}
525597

526598
/** @return array<string, Prompt> */
527599
public function getPrompts(): array
528600
{
529-
return array_map(fn ($registration) => $registration['prompt'], $this->prompts);
601+
return array_map(fn($registration) => $registration['prompt'], $this->prompts);
530602
}
531603

532604
/** @return array<string, ResourceTemplate> */
533605
public function getResourceTemplates(): array
534606
{
535-
return array_map(fn ($registration) => $registration['resourceTemplate'], $this->resourceTemplates);
607+
return array_map(fn($registration) => $registration['resourceTemplate'], $this->resourceTemplates);
608+
}
609+
610+
/**
611+
* @param 'ref/prompt'|'ref/resource' $refType
612+
* @param string $elementIdentifier Name for prompts, URI template for resource templates
613+
* @param string $argumentName
614+
* @return class-string<ArgumentCompletionProviderInterface>|null
615+
*/
616+
public function getCompletionProvider(string $refType, string $identifier, string $argument): ?string
617+
{
618+
return $this->completionProviders[$refType][$identifier][$argument] ?? null;
536619
}
537620
}

0 commit comments

Comments
 (0)