Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Laravel\Mcp\Server\Contracts\Transport;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Methods\CallTool;
use Laravel\Mcp\Server\Methods\CompletionComplete;
use Laravel\Mcp\Server\Methods\GetPrompt;
use Laravel\Mcp\Server\Methods\Initialize;
use Laravel\Mcp\Server\Methods\ListPrompts;
Expand Down Expand Up @@ -98,6 +99,7 @@ abstract class Server
'resources/templates/list' => ListResourceTemplates::class,
'prompts/list' => ListPrompts::class,
'prompts/get' => GetPrompt::class,
'completion/complete' => CompletionComplete::class,
'ping' => Ping::class,
];

Expand Down
27 changes: 27 additions & 0 deletions src/Server/Completions/ArrayCompletionResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Completions;

class ArrayCompletionResponse extends CompletionResponse
{
/**
* @param array<int, string> $items
*/
public function __construct(private array $items)
{
parent::__construct([]);
}

public function resolve(string $value): DirectCompletionResponse
{
$filtered = CompletionHelper::filterByPrefix($this->items, $value);

$hasMore = count($filtered) > self::MAX_VALUES;

$truncated = array_slice($filtered, 0, self::MAX_VALUES);

return new DirectCompletionResponse($truncated, $hasMore);
}
}
35 changes: 35 additions & 0 deletions src/Server/Completions/CallbackCompletionResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Completions;

use Illuminate\Support\Arr;

class CallbackCompletionResponse extends CompletionResponse
{
/**
* @param callable(string): (CompletionResponse|array<int, string>|string) $callback
*/
public function __construct(private $callback)
{
parent::__construct([]);
}

public function resolve(string $value): CompletionResponse
{
$result = ($this->callback)($value);

if ($result instanceof CompletionResponse) {
return $result;
}

$items = Arr::wrap($result);

$hasMore = count($items) > self::MAX_VALUES;

$truncated = array_slice($items, 0, self::MAX_VALUES);

return new DirectCompletionResponse($truncated, $hasMore);
}
}
28 changes: 28 additions & 0 deletions src/Server/Completions/CompletionHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Completions;

use Illuminate\Support\Str;

class CompletionHelper
{
/**
* @param array<string> $items
* @return array<string>
*/
public static function filterByPrefix(array $items, string $prefix): array
{
if ($prefix === '') {
return $items;
}

$prefixLower = Str::lower($prefix);

return array_values(array_filter(
$items,
fn (string $item) => Str::startsWith(Str::lower($item), $prefixLower)
));
}
}
101 changes: 101 additions & 0 deletions src/Server/Completions/CompletionResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Completions;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use UnitEnum;

/**
* @implements Arrayable<string, mixed>
*/
abstract class CompletionResponse implements Arrayable
{
protected const MAX_VALUES = 100;

/**
* @param array<int, string> $values
*/
public function __construct(
protected array $values,
protected bool $hasMore = false,
) {
if (count($values) > self::MAX_VALUES) {
throw new InvalidArgumentException(
sprintf('Completion values cannot exceed %d items (received %d)', self::MAX_VALUES, count($values))
);
}
}

/**
* @param array<int, string>|string $values
*/
public static function from(array|string $values): CompletionResponse
{
$values = Arr::wrap($values);

$hasMore = count($values) > self::MAX_VALUES;

if ($hasMore) {
$values = array_slice($values, 0, self::MAX_VALUES);
}

return new DirectCompletionResponse($values, $hasMore);
}

public static function empty(): CompletionResponse
{
return new DirectCompletionResponse([]);
}

/**
* @param array<int, string> $items
*/
public static function fromArray(array $items): CompletionResponse
{
return new ArrayCompletionResponse($items);
}

/**
* @param class-string<UnitEnum> $enumClass
*/
public static function fromEnum(string $enumClass): CompletionResponse
{
return new EnumCompletionResponse($enumClass);
}

public static function fromCallback(callable $callback): CompletionResponse
{
return new CallbackCompletionResponse($callback);
}

abstract public function resolve(string $value): CompletionResponse;

/**
* @return array<int, string>
*/
public function values(): array
{
return $this->values;
}

public function hasMore(): bool
{
return $this->hasMore;
}

/**
* @return array{values: array<int, string>, total: int, hasMore: bool}
*/
public function toArray(): array
{
return [
'values' => $this->values,
'total' => count($this->values),
'hasMore' => $this->hasMore,
];
}
}
13 changes: 13 additions & 0 deletions src/Server/Completions/DirectCompletionResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Completions;

class DirectCompletionResponse extends CompletionResponse
{
public function resolve(string $value): DirectCompletionResponse
{
return $this;
}
}
40 changes: 40 additions & 0 deletions src/Server/Completions/EnumCompletionResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Completions;

use BackedEnum;
use InvalidArgumentException;
use UnitEnum;

class EnumCompletionResponse extends CompletionResponse
{
/**
* @param class-string<UnitEnum> $enumClass
*/
public function __construct(private string $enumClass)
{
if (! enum_exists($enumClass)) {
throw new InvalidArgumentException("Class [{$enumClass}] is not an enum.");
}

parent::__construct([]);
}

public function resolve(string $value): DirectCompletionResponse
{
$enumValues = array_map(
fn (UnitEnum $case): string => $case instanceof BackedEnum ? (string) $case->value : $case->name,
$this->enumClass::cases()
);

$filtered = CompletionHelper::filterByPrefix($enumValues, $value);

$hasMore = count($filtered) > self::MAX_VALUES;

$truncated = array_slice($filtered, 0, self::MAX_VALUES);

return new DirectCompletionResponse($truncated, $hasMore);
}
}
15 changes: 15 additions & 0 deletions src/Server/Contracts/SupportsCompletion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Contracts;

use Laravel\Mcp\Server\Completions\CompletionResponse;

interface SupportsCompletion
{
/**
* @param array<string, mixed> $context
*/
public function complete(string $argument, string $value, array $context): CompletionResponse;
}
113 changes: 113 additions & 0 deletions src/Server/Methods/CompletionComplete.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Methods;

use Illuminate\Container\Container;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Contracts\SupportsCompletion;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Methods\Concerns\ResolvesPrompts;
use Laravel\Mcp\Server\Methods\Concerns\ResolvesResources;
use Laravel\Mcp\Server\Primitive;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;

class CompletionComplete implements Method
{
use ResolvesPrompts;
use ResolvesResources;

public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
if (! isset($context->serverCapabilities['completions'])) {
throw new JsonRpcException(
'Server does not support completions capability.',
-32601,
$request->id,
);
}

$ref = $request->get('ref');
$argument = $request->get('argument');

if (is_null($ref) || is_null($argument)) {
throw new JsonRpcException(
'Missing required parameters: ref and argument',
-32602,
$request->id,
);
}

try {
$primitive = $this->resolvePrimitive($ref, $context);
} catch (InvalidArgumentException $invalidArgumentException) {
throw new JsonRpcException($invalidArgumentException->getMessage(), -32602, $request->id);
}

if (! $primitive instanceof SupportsCompletion) {
throw new JsonRpcException(
'The referenced primitive does not support completion.',
-32602,
$request->id,
);
}

$argumentName = $argument['name'] ?? null;
$argumentValue = $argument['value'] ?? '';

if (is_null($argumentName)) {
throw new JsonRpcException(
'Missing argument name.',
-32602,
$request->id,
);
}

$contextArguments = $request->get('context')['arguments'] ?? [];

$result = $this->invokeCompletion($primitive, $argumentName, $argumentValue, $contextArguments);

return JsonRpcResponse::result($request->id, [
'completion' => $result->toArray(),
]);
}

/**
* @param array<string, mixed> $ref
*/
protected function resolvePrimitive(array $ref, ServerContext $context): Primitive|Resource|HasUriTemplate
{
return match (Arr::get($ref, 'type')) {
'ref/prompt' => $this->resolvePrompt(Arr::get($ref, 'name'), $context),
'ref/resource' => $this->resolveResource(Arr::get($ref, 'uri'), $context),
default => throw new InvalidArgumentException('Invalid reference type. Expected ref/prompt or ref/resource.'),
};
}

/**
* @param array<string, mixed> $context
*/
protected function invokeCompletion(
SupportsCompletion $primitive,
string $argumentName,
string $argumentValue,
array $context
): mixed {
$container = Container::getInstance();

$result = $container->call($primitive->complete(...), [
'argument' => $argumentName,
'value' => $argumentValue,
'context' => $context,
]);

return $result->resolve($argumentValue);
}
}
Loading