Skip to content

Commit 2251e53

Browse files
committed
Implement server side completions API
1 parent 24da4f4 commit 2251e53

9 files changed

Lines changed: 741 additions & 6 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This file was introduced during the v1.7.x series. Structured entries below cove
2121
- URI template matching engine that decides whether a concrete URI matches a
2222
registered URI template and extracts the variable values.
2323
- Implements resources templates as an API that aligns with the rest of the SDK.
24+
- Server-side completions API
2425

2526
## [1.7.2]
2627

src/Server/McpServer.php

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
use Mcp\Server\Transport\Http\StandardPhpAdapter;
3535
use Mcp\Types\BlobResourceContents;
3636
use Mcp\Types\CallToolResult;
37+
use Mcp\Types\CompleteResult;
38+
use Mcp\Types\CompletionContext;
39+
use Mcp\Types\CompletionObject;
3740
use Mcp\Types\GetPromptResult;
3841
use Mcp\Types\ListPromptsResult;
3942
use Mcp\Types\ListResourcesResult;
@@ -42,8 +45,10 @@
4245
use Mcp\Types\Prompt;
4346
use Mcp\Types\PromptArgument;
4447
use Mcp\Types\PromptMessage;
48+
use Mcp\Types\PromptReference;
4549
use Mcp\Types\ReadResourceResult;
4650
use Mcp\Types\Resource;
51+
use Mcp\Types\ResourceReference;
4752
use Mcp\Types\ResourceTemplate;
4853
use Mcp\Types\Role;
4954
use Mcp\Types\Task;
@@ -127,6 +132,15 @@ class McpServer
127132
*/
128133
protected array $resourceTemplateHandlers = [];
129134

135+
/** @var array<string, callable> Prompt-argument completion providers, keyed "promptName\0argName". */
136+
protected array $promptCompletionProviders = [];
137+
138+
/** @var array<string, callable> Resource-template completion providers, keyed "uriTemplate\0argName". */
139+
protected array $resourceTemplateCompletionProviders = [];
140+
141+
/** @var bool Whether the completion/complete handler has been registered. */
142+
protected bool $completionHandlerRegistered = false;
143+
130144
/** @var array<string, mixed> [Added] HTTP transport options. */
131145
protected array $httpOptions = [];
132146

@@ -563,6 +577,62 @@ private function normalizeReadResourceResult(mixed $result, string $uri, string
563577
throw McpServerException::invalidResourceResult($result);
564578
}
565579

580+
/**
581+
* Register a completion provider for a prompt argument.
582+
*
583+
* The provider supplies autocomplete suggestions as the user types a value
584+
* for the named argument of the named prompt. Registering any completion
585+
* provider causes the server to advertise the `completions` capability.
586+
*
587+
* The provider is called as `$provider(string $value, array $context = [])`:
588+
* - `$value` is the partial argument value typed so far.
589+
* - `$context` is the map of already-resolved argument values the client
590+
* sent (empty when none) — useful to filter on a prior selection. A
591+
* provider that doesn't need context simply omits the second parameter.
592+
*
593+
* It may return a `string[]` (auto-wrapped, truncated to 100 with
594+
* `hasMore`/`total` if longer), a {@see CompletionObject}, or a
595+
* {@see CompleteResult} (both passed through after validation).
596+
*
597+
* @param string $promptName The prompt the argument belongs to
598+
* @param string $argumentName The argument to complete
599+
* @param callable $provider The suggestion provider
600+
* @return self For method chaining
601+
*/
602+
public function completionForPrompt(
603+
string $promptName,
604+
string $argumentName,
605+
callable $provider
606+
): self {
607+
$this->promptCompletionProviders[$this->completionKey($promptName, $argumentName)] = $provider;
608+
$this->ensureCompletionHandler();
609+
return $this;
610+
}
611+
612+
/**
613+
* Register a completion provider for a resource-template argument.
614+
*
615+
* Identical contract to {@see completionForPrompt()}, but keyed on a
616+
* registered `uriTemplate` and one of its variables. The `$uriTemplate`
617+
* must match the string passed to {@see resourceTemplate()}; a completion
618+
* request naming a template that was never registered yields a -32602
619+
* error rather than an empty result.
620+
*
621+
* @param string $uriTemplate The registered template string
622+
* @param string $argumentName The template variable to complete
623+
* @param callable $provider The suggestion provider
624+
* @return self For method chaining
625+
*/
626+
public function completionForResourceTemplate(
627+
string $uriTemplate,
628+
string $argumentName,
629+
callable $provider
630+
): self {
631+
$this->resourceTemplateCompletionProviders[$this->completionKey($uriTemplate, $argumentName)] = $provider;
632+
$this->ensureCompletionHandler();
633+
return $this;
634+
}
635+
566636
// -----------------------------------------------------------------------
567637
// Configuration — [Added]
568638
// -----------------------------------------------------------------------
@@ -955,6 +1025,131 @@ protected function registerDefaultHandlers(): void
9551025
});
9561026
}
9571027

1028+
/**
1029+
* Build the composite key for a completion provider.
1030+
*
1031+
* Uses a NUL separator so a name containing the separator cannot collide
1032+
* with a different (name, argument) pair.
1033+
*/
1034+
private function completionKey(string $refName, string $argumentName): string
1035+
{
1036+
return $refName . "\0" . $argumentName;
1037+
}
1038+
1039+
/**
1040+
* Whether any registered resource template uses the given URI template.
1041+
*/
1042+
private function hasResourceTemplate(string $uriTemplate): bool
1043+
{
1044+
foreach ($this->resourceTemplates as $template) {
1045+
if ($template->uriTemplate === $uriTemplate) {
1046+
return true;
1047+
}
1048+
}
1049+
return false;
1050+
}
1051+
1052+
/**
1053+
* Lazily register the completion/complete handler on first provider use.
1054+
*
1055+
* Keeping registration lazy means Server::getCapabilities() only advertises
1056+
* the `completions` capability for servers that actually register a
1057+
* provider.
1058+
*/
1059+
private function ensureCompletionHandler(): void
1060+
{
1061+
if ($this->completionHandlerRegistered) {
1062+
return;
1063+
}
1064+
$this->completionHandlerRegistered = true;
1065+
1066+
$this->server->registerHandler('completion/complete', function ($params) {
1067+
$ref = is_object($params) ? ($params->ref ?? null) : null;
1068+
$argument = is_object($params) ? ($params->argument ?? null) : null;
1069+
$argName = is_object($argument) ? ($argument->name ?? '') : '';
1070+
$argValue = is_object($argument) ? ($argument->value ?? '') : '';
1071+
1072+
// Already-resolved arguments for multi-argument completion.
1073+
$context = [];
1074+
$ctx = is_object($params) ? ($params->context ?? null) : null;
1075+
if ($ctx instanceof CompletionContext) {
1076+
$context = $ctx->arguments;
1077+
}
1078+
1079+
// An invalid *reference* is a -32602 error, not an empty result.
1080+
if ($ref instanceof PromptReference) {
1081+
if (!isset($this->promptHandlers[$ref->name])) {
1082+
throw McpServerException::unknownPrompt($ref->name);
1083+
}
1084+
$provider = $this->promptCompletionProviders[$this->completionKey($ref->name, $argName)] ?? null;
1085+
} elseif ($ref instanceof ResourceReference) {
1086+
// ResourceReference->uri carries the registered template string.
1087+
if (!$this->hasResourceTemplate($ref->uri)) {
1088+
throw McpServerException::unknownResourceTemplate($ref->uri);
1089+
}
1090+
$provider = $this->resourceTemplateCompletionProviders[$this->completionKey($ref->uri, $argName)] ?? null;
1091+
} else {
1092+
throw McpServerException::invalidCompletionRef();
1093+
}
1094+
1095+
// Valid ref but no provider for this specific argument: no suggestions.
1096+
if ($provider === null) {
1097+
return new CompleteResult(completion: new CompletionObject(values: []));
1098+
}
1099+
1100+
return $this->normalizeCompletionResult($provider($argValue, $context));
1101+
});
1102+
}
1103+
1104+
/**
1105+
* Normalize a completion provider's return value into a CompleteResult.
1106+
*
1107+
* Enforces the spec's 100-value cap on the SEND side (BaseSession does not
1108+
* validate outgoing results):
1109+
* - A `string[]` longer than 100 is truncated to the first 100, with
1110+
* `hasMore: true` and `total` set to the full count; truncation is
1111+
* logged so it is not silent.
1112+
* - A hand-built CompletionObject/CompleteResult is validated (which throws
1113+
* above 100), so an author-built oversized response fails loudly at the
1114+
* source rather than emitting a spec-violating payload.
1115+
*
1116+
* @param mixed $result The raw provider return value
1117+
* @throws McpServerException If the result type is unsupported
1118+
*/
1119+
private function normalizeCompletionResult(mixed $result): CompleteResult
1120+
{
1121+
if ($result instanceof CompleteResult) {
1122+
$result->completion->validate();
1123+
return $result;
1124+
}
1125+
1126+
if ($result instanceof CompletionObject) {
1127+
$result->validate();
1128+
return new CompleteResult(completion: $result);
1129+
}
1130+
1131+
if (is_array($result)) {
1132+
$values = array_values(array_map(static fn ($v): string => (string)$v, $result));
1133+
$total = count($values);
1134+
1135+
if ($total > 100) {
1136+
$this->logger->debug(sprintf(
1137+
'Completion provider returned %d values; truncating to 100 and setting hasMore=true.',
1138+
$total
1139+
));
1140+
return new CompleteResult(completion: new CompletionObject(
1141+
values: array_slice($values, 0, 100),
1142+
total: $total,
1143+
hasMore: true,
1144+
));
1145+
}
1146+
1147+
return new CompleteResult(completion: new CompletionObject(values: $values));
1148+
}
1149+
1150+
throw McpServerException::invalidCompletionResult($result);
1151+
}
1152+
9581153
// -----------------------------------------------------------------------
9591154
// Internal — Reflection Helpers
9601155
// -----------------------------------------------------------------------

src/Server/McpServerException.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,44 @@ public static function unknownResource(string $uri): self
110110
));
111111
}
112112

113+
/**
114+
* Create a new exception for an unknown resource template.
115+
*
116+
* Used by the completion handler when a `ref/resource` completion names a
117+
* URI that matches no registered template. The completion spec classifies
118+
* an invalid reference as Invalid params (-32602), not "resource not found".
119+
*/
120+
public static function unknownResourceTemplate(string $uriTemplate): self
121+
{
122+
return new self(new ErrorData(
123+
code: -32602,
124+
message: "Unknown resource template: {$uriTemplate}"
125+
));
126+
}
127+
128+
/**
129+
* Create a new exception for a malformed/invalid completion reference.
130+
*/
131+
public static function invalidCompletionRef(): self
132+
{
133+
return new self(new ErrorData(
134+
code: -32602,
135+
message: "Invalid completion reference"
136+
));
137+
}
138+
139+
/**
140+
* Create a new exception for an invalid completion provider result.
141+
*/
142+
public static function invalidCompletionResult(mixed $result): self
143+
{
144+
$type = is_object($result) ? get_class($result) : gettype($result);
145+
return new self(new ErrorData(
146+
code: -32603,
147+
message: "Invalid completion provider result: expected array of strings, CompletionObject, or CompleteResult, got {$type}"
148+
));
149+
}
150+
113151
/**
114152
* Create a new exception for a task that was not found.
115153
*/

src/Server/Server.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Mcp\Types\ServerResourcesCapability;
3636
use Mcp\Types\ServerToolsCapability;
3737
use Mcp\Types\ServerLoggingCapability;
38+
use Mcp\Types\ServerCompletionsCapability;
3839
use Mcp\Types\TaskCapability;
3940
use Mcp\Types\ExperimentalCapabilities;
4041
use Mcp\Types\LoggingLevel;
@@ -141,6 +142,11 @@ public function getCapabilities(
141142
);
142143
}
143144

145+
$completionsCapability = null;
146+
if (isset($this->requestHandlers['completion/complete'])) {
147+
$completionsCapability = new ServerCompletionsCapability();
148+
}
149+
144150
// Build tasks capability if task handlers are registered
145151
$tasksCapability = null;
146152
$hasTaskGet = isset($this->requestHandlers['tasks/get']);
@@ -161,6 +167,7 @@ public function getCapabilities(
161167
resources: $resourcesCapability,
162168
tools: $toolsCapability,
163169
logging: $loggingCapability,
170+
completions: $completionsCapability,
164171
experimental: ExperimentalCapabilities::fromArray($experimentalCapabilities),
165172
tasks: $tasksCapability,
166173
);

src/Types/ClientRequest.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,12 @@ private static function createCompleteRequest(array $params): self {
215215
default => throw new \InvalidArgumentException("Unknown ref type: {$refData['type']}")
216216
};
217217

218+
// Optional completion context (already-resolved argument values).
219+
$contextData = $params['context'] ?? null;
220+
$context = is_array($contextData) ? CompletionContext::fromArray($contextData) : null;
221+
218222
// Construct the new CompleteRequestParams
219-
$reqParams = new CompleteRequestParams($argument, $ref);
223+
$reqParams = new CompleteRequestParams($argument, $ref, context: $context);
220224

221225
// Now pass that to CompleteRequest
222226
return new self(new CompleteRequest($reqParams));

src/Types/CompleteRequestParams.php

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,28 +33,33 @@
3333
* Params for CompleteRequest:
3434
* {
3535
* argument: CompletionArgument,
36-
* ref: PromptReference | ResourceReference
36+
* ref: PromptReference | ResourceReference,
37+
* context?: CompletionContext
3738
* }
3839
*/
3940
class CompleteRequestParams extends RequestParams
4041
{
4142
public function __construct(
4243
public readonly CompletionArgument $argument,
4344
public readonly PromptReference|ResourceReference $ref,
44-
?Meta $_meta = null
45+
?Meta $_meta = null,
46+
public readonly ?CompletionContext $context = null,
4547
) {
4648
parent::__construct($_meta);
4749
}
4850

4951
public function validate(): void
5052
{
5153
parent::validate(); // validates $_meta if present
52-
54+
5355
// Validate the CompletionArgument
5456
$this->argument->validate();
55-
57+
5658
// Validate the reference
5759
$this->ref->validate();
60+
61+
// Validate the context if present
62+
$this->context?->validate();
5863
}
5964

6065
public function jsonSerialize(): mixed
@@ -63,7 +68,11 @@ public function jsonSerialize(): mixed
6368
'argument' => $this->argument,
6469
'ref' => $this->ref,
6570
];
66-
71+
72+
if ($this->context !== null) {
73+
$data['context'] = $this->context;
74+
}
75+
6776
// Get parent data
6877
$parentData = parent::jsonSerialize();
6978

0 commit comments

Comments
 (0)