Skip to content

Commit 490ef1d

Browse files
authored
Add Completion Support (#127)
* Add Completion Support * Refactor completion handling and improve request execution flow * Rename Class * Improve completion methods to accept context arguments * Rename completion response methods * Refactor CompletionResponse methods to use `from` instead of `make` * Rename ListCompletionResponse to ArrayCompletionResponse and update related methods to include hasMore flag * Refactor * Update `CompletionComplete` to return an empty array instead of throwing an exception when primitive does not support completion * Refactor to match * Fix code styling * Fix Typo * Add result * Remove callback feature * Refactor * Refactor * Rename SupportsCompletion to Completable
1 parent c24c22d commit 490ef1d

22 files changed

+1547
-49
lines changed

src/Server.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Laravel\Mcp\Server\Contracts\Transport;
1111
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
1212
use Laravel\Mcp\Server\Methods\CallTool;
13+
use Laravel\Mcp\Server\Methods\CompletionComplete;
1314
use Laravel\Mcp\Server\Methods\GetPrompt;
1415
use Laravel\Mcp\Server\Methods\Initialize;
1516
use Laravel\Mcp\Server\Methods\ListPrompts;
@@ -35,6 +36,14 @@
3536
*/
3637
abstract class Server
3738
{
39+
public const CAPABILITY_TOOLS = 'tools';
40+
41+
public const CAPABILITY_RESOURCES = 'resources';
42+
43+
public const CAPABILITY_PROMPTS = 'prompts';
44+
45+
public const CAPABILITY_COMPLETIONS = 'completions';
46+
3847
protected string $name = 'Laravel MCP Server';
3948

4049
protected string $version = '0.0.1';
@@ -57,13 +66,13 @@ abstract class Server
5766
* @var array<string, array<string, bool>|stdClass|string>
5867
*/
5968
protected array $capabilities = [
60-
'tools' => [
69+
self::CAPABILITY_TOOLS => [
6170
'listChanged' => false,
6271
],
63-
'resources' => [
72+
self::CAPABILITY_RESOURCES => [
6473
'listChanged' => false,
6574
],
66-
'prompts' => [
75+
self::CAPABILITY_PROMPTS => [
6776
'listChanged' => false,
6877
],
6978
];
@@ -98,6 +107,7 @@ abstract class Server
98107
'resources/templates/list' => ListResourceTemplates::class,
99108
'prompts/list' => ListPrompts::class,
100109
'prompts/get' => GetPrompt::class,
110+
'completion/complete' => CompletionComplete::class,
101111
'ping' => Ping::class,
102112
];
103113

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Completions;
6+
7+
class ArrayCompletionResponse extends CompletionResponse
8+
{
9+
/**
10+
* @param array<int, string> $items
11+
*/
12+
public function __construct(private array $items)
13+
{
14+
parent::__construct([]);
15+
}
16+
17+
public function resolve(string $value): DirectCompletionResponse
18+
{
19+
$filtered = CompletionHelper::filterByPrefix($this->items, $value);
20+
21+
$hasMore = count($filtered) > self::MAX_VALUES;
22+
23+
$truncated = array_slice($filtered, 0, self::MAX_VALUES);
24+
25+
return new DirectCompletionResponse($truncated, $hasMore);
26+
}
27+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Completions;
6+
7+
use Illuminate\Support\Str;
8+
9+
class CompletionHelper
10+
{
11+
/**
12+
* @param array<string> $items
13+
* @return array<string>
14+
*/
15+
public static function filterByPrefix(array $items, string $prefix): array
16+
{
17+
if ($prefix === '') {
18+
return $items;
19+
}
20+
21+
$prefixLower = Str::lower($prefix);
22+
23+
return array_values(array_filter(
24+
$items,
25+
fn (string $item) => Str::startsWith(Str::lower($item), $prefixLower)
26+
));
27+
}
28+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Completions;
6+
7+
use Illuminate\Contracts\Support\Arrayable;
8+
use InvalidArgumentException;
9+
use UnitEnum;
10+
11+
/**
12+
* @implements Arrayable<string, mixed>
13+
*/
14+
abstract class CompletionResponse implements Arrayable
15+
{
16+
protected const MAX_VALUES = 100;
17+
18+
/**
19+
* @param array<int, string> $values
20+
*/
21+
public function __construct(
22+
protected array $values,
23+
protected bool $hasMore = false,
24+
) {
25+
if (count($values) > self::MAX_VALUES) {
26+
throw new InvalidArgumentException(
27+
sprintf('Completion values cannot exceed %d items (received %d)', self::MAX_VALUES, count($values))
28+
);
29+
}
30+
}
31+
32+
public static function empty(): CompletionResponse
33+
{
34+
return new DirectCompletionResponse([]);
35+
}
36+
37+
/**
38+
* @param array<int, string>|class-string<UnitEnum> $items
39+
*/
40+
public static function match(array|string $items): CompletionResponse
41+
{
42+
if (is_string($items)) {
43+
return new EnumCompletionResponse($items);
44+
}
45+
46+
return new ArrayCompletionResponse($items);
47+
}
48+
49+
/**
50+
* @param array<int, string>|string $items
51+
*/
52+
public static function result(array|string $items): CompletionResponse
53+
{
54+
if (is_array($items)) {
55+
$hasMore = count($items) > self::MAX_VALUES;
56+
$truncated = array_slice($items, 0, self::MAX_VALUES);
57+
58+
return new DirectCompletionResponse($truncated, $hasMore);
59+
}
60+
61+
return new DirectCompletionResponse([$items], false);
62+
}
63+
64+
abstract public function resolve(string $value): CompletionResponse;
65+
66+
/**
67+
* @return array<int, string>
68+
*/
69+
public function values(): array
70+
{
71+
return $this->values;
72+
}
73+
74+
public function hasMore(): bool
75+
{
76+
return $this->hasMore;
77+
}
78+
79+
/**
80+
* @return array{values: array<int, string>, total: int, hasMore: bool}
81+
*/
82+
public function toArray(): array
83+
{
84+
return [
85+
'values' => $this->values,
86+
'total' => count($this->values),
87+
'hasMore' => $this->hasMore,
88+
];
89+
}
90+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Completions;
6+
7+
class DirectCompletionResponse extends CompletionResponse
8+
{
9+
public function resolve(string $value): DirectCompletionResponse
10+
{
11+
return $this;
12+
}
13+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Completions;
6+
7+
use BackedEnum;
8+
use InvalidArgumentException;
9+
use UnitEnum;
10+
11+
class EnumCompletionResponse extends CompletionResponse
12+
{
13+
/**
14+
* @param class-string<UnitEnum> $enumClass
15+
*/
16+
public function __construct(private string $enumClass)
17+
{
18+
if (! enum_exists($enumClass)) {
19+
throw new InvalidArgumentException("Class [{$enumClass}] is not an enum.");
20+
}
21+
22+
parent::__construct([]);
23+
}
24+
25+
public function resolve(string $value): DirectCompletionResponse
26+
{
27+
$enumValues = array_map(
28+
fn (UnitEnum $case): string => $case instanceof BackedEnum ? (string) $case->value : $case->name,
29+
$this->enumClass::cases()
30+
);
31+
32+
$filtered = CompletionHelper::filterByPrefix($enumValues, $value);
33+
34+
$hasMore = count($filtered) > self::MAX_VALUES;
35+
36+
$truncated = array_slice($filtered, 0, self::MAX_VALUES);
37+
38+
return new DirectCompletionResponse($truncated, $hasMore);
39+
}
40+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Contracts;
6+
7+
use Laravel\Mcp\Server\Completions\CompletionResponse;
8+
9+
interface Completable
10+
{
11+
/**
12+
* @param array<string, mixed> $context
13+
*/
14+
public function complete(string $argument, string $value, array $context): CompletionResponse;
15+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Methods;
6+
7+
use Illuminate\Container\Container;
8+
use Illuminate\Support\Arr;
9+
use InvalidArgumentException;
10+
use Laravel\Mcp\Server;
11+
use Laravel\Mcp\Server\Completions\CompletionResponse;
12+
use Laravel\Mcp\Server\Contracts\Completable;
13+
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
14+
use Laravel\Mcp\Server\Contracts\Method;
15+
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
16+
use Laravel\Mcp\Server\Methods\Concerns\ResolvesPrompts;
17+
use Laravel\Mcp\Server\Methods\Concerns\ResolvesResources;
18+
use Laravel\Mcp\Server\Prompt;
19+
use Laravel\Mcp\Server\Resource;
20+
use Laravel\Mcp\Server\ServerContext;
21+
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
22+
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
23+
24+
class CompletionComplete implements Method
25+
{
26+
use ResolvesPrompts;
27+
use ResolvesResources;
28+
29+
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
30+
{
31+
if (! $context->hasCapability(Server::CAPABILITY_COMPLETIONS)) {
32+
throw new JsonRpcException(
33+
'Server does not support completions capability.',
34+
-32601,
35+
$request->id,
36+
);
37+
}
38+
39+
$ref = $request->get('ref');
40+
$argument = $request->get('argument');
41+
42+
if (is_null($ref) || is_null($argument)) {
43+
throw new JsonRpcException(
44+
'Missing required parameters: ref and argument',
45+
-32602,
46+
$request->id,
47+
);
48+
}
49+
50+
try {
51+
$primitive = $this->resolvePrimitive($ref, $context);
52+
} catch (InvalidArgumentException $invalidArgumentException) {
53+
throw new JsonRpcException($invalidArgumentException->getMessage(), -32602, $request->id);
54+
}
55+
56+
if (! $primitive instanceof Completable) {
57+
$result = CompletionResponse::empty();
58+
59+
return JsonRpcResponse::result($request->id, [
60+
'completion' => $result->toArray(),
61+
]);
62+
}
63+
64+
$argumentName = Arr::get($argument, 'name');
65+
$argumentValue = Arr::get($argument, 'value', '');
66+
67+
if (is_null($argumentName)) {
68+
throw new JsonRpcException(
69+
'Missing argument name.',
70+
-32602,
71+
$request->id,
72+
);
73+
}
74+
75+
$contextArguments = Arr::get($request->get('context'), 'arguments', []);
76+
77+
$result = $this->invokeCompletion($primitive, $argumentName, $argumentValue, $contextArguments);
78+
79+
return JsonRpcResponse::result($request->id, [
80+
'completion' => $result->toArray(),
81+
]);
82+
}
83+
84+
/**
85+
* @param array<string, mixed> $ref
86+
*/
87+
protected function resolvePrimitive(array $ref, ServerContext $context): Prompt|Resource|HasUriTemplate
88+
{
89+
return match (Arr::get($ref, 'type')) {
90+
'ref/prompt' => $this->resolvePrompt(Arr::get($ref, 'name'), $context),
91+
'ref/resource' => $this->resolveResource(Arr::get($ref, 'uri'), $context),
92+
default => throw new InvalidArgumentException('Invalid reference type. Expected ref/prompt or ref/resource.'),
93+
};
94+
}
95+
96+
/**
97+
* @param array<string, mixed> $context
98+
*/
99+
protected function invokeCompletion(
100+
Completable $primitive,
101+
string $argumentName,
102+
string $argumentValue,
103+
array $context
104+
): mixed {
105+
$container = Container::getInstance();
106+
107+
$result = $container->call($primitive->complete(...), [
108+
'argument' => $argumentName,
109+
'value' => $argumentValue,
110+
'context' => $context,
111+
]);
112+
113+
return $result->resolve($argumentValue);
114+
}
115+
}

0 commit comments

Comments
 (0)