Skip to content
Open
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
16 changes: 13 additions & 3 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 All @@ -35,6 +36,14 @@
*/
abstract class Server
{
public const CAPABILITY_TOOLS = 'tools';

public const CAPABILITY_RESOURCES = 'resources';

public const CAPABILITY_PROMPTS = 'prompts';

public const CAPABILITY_COMPLETIONS = 'completions';

protected string $name = 'Laravel MCP Server';

protected string $version = '0.0.1';
Expand All @@ -57,13 +66,13 @@ abstract class Server
* @var array<string, array<string, bool>|stdClass|string>
*/
protected array $capabilities = [
'tools' => [
self::CAPABILITY_TOOLS => [
'listChanged' => false,
],
'resources' => [
self::CAPABILITY_RESOURCES => [
'listChanged' => false,
],
'prompts' => [
self::CAPABILITY_PROMPTS => [
'listChanged' => false,
],
];
Expand Down Expand Up @@ -98,6 +107,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);
}
}
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)
));
}
}
90 changes: 90 additions & 0 deletions src/Server/Completions/CompletionResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Completions;

use Illuminate\Contracts\Support\Arrayable;
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))
);
}
}

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

/**
* @param array<int, string>|class-string<UnitEnum> $items
*/
public static function match(array|string $items): CompletionResponse
{
if (is_string($items)) {
return new EnumCompletionResponse($items);
}

return new ArrayCompletionResponse($items);
}

/**
* @param array<int, string>|string $items
*/
public static function result(array|string $items): CompletionResponse
{
if (is_array($items)) {
$hasMore = count($items) > self::MAX_VALUES;
$truncated = array_slice($items, 0, self::MAX_VALUES);

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

return new DirectCompletionResponse([$items], false);
}

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;
}
115 changes: 115 additions & 0 deletions src/Server/Methods/CompletionComplete.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Methods;

use Illuminate\Container\Container;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Completions\CompletionResponse;
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\Prompt;
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 (! $context->hasCapability(Server::CAPABILITY_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) {
$result = CompletionResponse::empty();

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

$argumentName = Arr::get($argument, 'name');
$argumentValue = Arr::get($argument, 'value', '');

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

$contextArguments = Arr::get($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): Prompt|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