diff --git a/src/Server.php b/src/Server.php index f3481bca..3a9fc9ec 100644 --- a/src/Server.php +++ b/src/Server.php @@ -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; @@ -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'; @@ -57,13 +66,13 @@ abstract class Server * @var array|stdClass|string> */ protected array $capabilities = [ - 'tools' => [ + self::CAPABILITY_TOOLS => [ 'listChanged' => false, ], - 'resources' => [ + self::CAPABILITY_RESOURCES => [ 'listChanged' => false, ], - 'prompts' => [ + self::CAPABILITY_PROMPTS => [ 'listChanged' => false, ], ]; @@ -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, ]; diff --git a/src/Server/Completions/ArrayCompletionResponse.php b/src/Server/Completions/ArrayCompletionResponse.php new file mode 100644 index 00000000..f32701d7 --- /dev/null +++ b/src/Server/Completions/ArrayCompletionResponse.php @@ -0,0 +1,27 @@ + $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); + } +} diff --git a/src/Server/Completions/CompletionHelper.php b/src/Server/Completions/CompletionHelper.php new file mode 100644 index 00000000..52cba823 --- /dev/null +++ b/src/Server/Completions/CompletionHelper.php @@ -0,0 +1,28 @@ + $items + * @return array + */ + 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) + )); + } +} diff --git a/src/Server/Completions/CompletionResponse.php b/src/Server/Completions/CompletionResponse.php new file mode 100644 index 00000000..f71eda15 --- /dev/null +++ b/src/Server/Completions/CompletionResponse.php @@ -0,0 +1,90 @@ + + */ +abstract class CompletionResponse implements Arrayable +{ + protected const MAX_VALUES = 100; + + /** + * @param array $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|class-string $items + */ + public static function match(array|string $items): CompletionResponse + { + if (is_string($items)) { + return new EnumCompletionResponse($items); + } + + return new ArrayCompletionResponse($items); + } + + /** + * @param array|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 + */ + public function values(): array + { + return $this->values; + } + + public function hasMore(): bool + { + return $this->hasMore; + } + + /** + * @return array{values: array, total: int, hasMore: bool} + */ + public function toArray(): array + { + return [ + 'values' => $this->values, + 'total' => count($this->values), + 'hasMore' => $this->hasMore, + ]; + } +} diff --git a/src/Server/Completions/DirectCompletionResponse.php b/src/Server/Completions/DirectCompletionResponse.php new file mode 100644 index 00000000..ffcaae35 --- /dev/null +++ b/src/Server/Completions/DirectCompletionResponse.php @@ -0,0 +1,13 @@ + $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); + } +} diff --git a/src/Server/Contracts/SupportsCompletion.php b/src/Server/Contracts/SupportsCompletion.php new file mode 100644 index 00000000..29110d02 --- /dev/null +++ b/src/Server/Contracts/SupportsCompletion.php @@ -0,0 +1,15 @@ + $context + */ + public function complete(string $argument, string $value, array $context): CompletionResponse; +} diff --git a/src/Server/Methods/CompletionComplete.php b/src/Server/Methods/CompletionComplete.php new file mode 100644 index 00000000..3ac2bf81 --- /dev/null +++ b/src/Server/Methods/CompletionComplete.php @@ -0,0 +1,115 @@ +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 $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 $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); + } +} diff --git a/src/Server/Methods/Concerns/ResolvesPrompts.php b/src/Server/Methods/Concerns/ResolvesPrompts.php new file mode 100644 index 00000000..7222a1d4 --- /dev/null +++ b/src/Server/Methods/Concerns/ResolvesPrompts.php @@ -0,0 +1,24 @@ +prompts()->first( + fn ($prompt): bool => $prompt->name() === $name, + fn () => throw new InvalidArgumentException("Prompt [{$name}] not found.") + ); + } +} diff --git a/src/Server/Methods/Concerns/ResolvesResources.php b/src/Server/Methods/Concerns/ResolvesResources.php new file mode 100644 index 00000000..30c8b98c --- /dev/null +++ b/src/Server/Methods/Concerns/ResolvesResources.php @@ -0,0 +1,29 @@ +resources()->first(fn ($resource): bool => $resource->uri() === $uri) + ?? $context->resourceTemplates()->first(fn ($template): bool => (string) $template->uriTemplate() === $uri + || $template->uriTemplate()->match($uri) !== null); + + if (! $resource) { + throw new InvalidArgumentException("Resource [{$uri}] not found."); + } + + return $resource; + } +} diff --git a/src/Server/Methods/GetPrompt.php b/src/Server/Methods/GetPrompt.php index 872e2ae0..b0b4b8b4 100644 --- a/src/Server/Methods/GetPrompt.php +++ b/src/Server/Methods/GetPrompt.php @@ -7,11 +7,13 @@ use Generator; use Illuminate\Container\Container; use Illuminate\Validation\ValidationException; +use InvalidArgumentException; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses; +use Laravel\Mcp\Server\Methods\Concerns\ResolvesPrompts; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; @@ -21,31 +23,19 @@ class GetPrompt implements Method { use InteractsWithResponses; + use ResolvesPrompts; /** * @return Generator|JsonRpcResponse - * - * @throws JsonRpcException */ public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse { - if (is_null($request->get('name'))) { - throw new JsonRpcException( - 'Missing [name] parameter.', - -32602, - $request->id, - ); + try { + $prompt = $this->resolvePrompt($request->get('name'), $context); + } catch (InvalidArgumentException $invalidArgumentException) { + throw new JsonRpcException($invalidArgumentException->getMessage(), -32602, $request->id); } - $prompt = $context->prompts() - ->first( - fn ($prompt): bool => $prompt->name() === $request->get('name'), - fn () => throw new JsonRpcException( - "Prompt [{$request->get('name')}] not found.", - -32602, - $request->id, - )); - try { // @phpstan-ignore-next-line $response = Container::getInstance()->call([$prompt, 'handle']); diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index 3ca0b0f9..0a6fcafc 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -8,6 +8,7 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Validation\ValidationException; +use InvalidArgumentException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -15,6 +16,7 @@ use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses; +use Laravel\Mcp\Server\Methods\Concerns\ResolvesResources; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; @@ -24,31 +26,21 @@ class ReadResource implements Method { use InteractsWithResponses; + use ResolvesResources; /** * @return Generator|JsonRpcResponse * - * @throws JsonRpcException * @throws BindingResolutionException */ public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse { - if (is_null($request->get('uri'))) { - throw new JsonRpcException( - 'Missing [uri] parameter.', - -32002, - $request->id, - ); - } - $uri = $request->get('uri'); - /** @var Resource|null $resource */ - $resource = $context->resources()->first(fn (Resource $resource): bool => $resource->uri() === $uri) ?? - $context->resourceTemplates()->first(fn (HasUriTemplate $template): bool => ! is_null($template->uriTemplate()->match($uri))); - - if (is_null($resource)) { - throw new JsonRpcException("Resource [{$uri}] not found.", -32002, $request->id); + try { + $resource = $this->resolveResource($uri, $context); + } catch (InvalidArgumentException $invalidArgumentException) { + throw new JsonRpcException($invalidArgumentException->getMessage(), -32002, $request->id); } try { diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index ab420e67..18bfd999 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -83,6 +83,11 @@ public function perPage(?int $requestedPerPage = null): int return min($requestedPerPage ?? $this->defaultPaginationLength, $this->maxPaginationLength); } + public function hasCapability(string $capability): bool + { + return array_key_exists($capability, $this->serverCapabilities); + } + /** * @template T of Primitive * diff --git a/src/Server/Testing/PendingTestResponse.php b/src/Server/Testing/PendingTestResponse.php index e452d091..fc8d7d45 100644 --- a/src/Server/Testing/PendingTestResponse.php +++ b/src/Server/Testing/PendingTestResponse.php @@ -6,6 +6,7 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Authenticatable; +use InvalidArgumentException; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Primitive; @@ -14,6 +15,7 @@ use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Transport\FakeTransporter; use Laravel\Mcp\Server\Transport\JsonRpcRequest; +use Laravel\Mcp\Server\Transport\JsonRpcResponse; class PendingTestResponse { @@ -54,6 +56,85 @@ public function resource(Resource|string $resource, array $arguments = []): Test return $this->run('resources/read', $resource, $arguments); } + /** + * @param class-string|Primitive $primitive + * @param array $currentArgs + */ + public function completion( + Primitive|string $primitive, + string $argumentName, + string $argumentValue = '', + array $currentArgs = [] + ): TestResponse { + $primitive = $this->resolvePrimitive($primitive); + $server = $this->initializeServer(); + + $request = new JsonRpcRequest( + uniqid(), + 'completion/complete', + [ + 'ref' => $this->buildCompletionRef($primitive), + 'argument' => [ + 'name' => $argumentName, + 'value' => $argumentValue, + ], + 'context' => [ + 'arguments' => $currentArgs, + ], + ], + ); + + $response = $this->executeRequest($server, $request); + + return new TestResponse($primitive, $response); + } + + /** + * @return array + */ + protected function buildCompletionRef(Primitive $primitive): array + { + return match (true) { + $primitive instanceof Prompt => [ + 'type' => 'ref/prompt', + 'name' => $primitive->name(), + ], + $primitive instanceof Resource => [ + 'type' => 'ref/resource', + 'uri' => $primitive->uri(), + ], + default => throw new InvalidArgumentException('Unsupported primitive type for completion.'), + }; + } + + protected function resolvePrimitive(Primitive|string $primitive): Primitive + { + return is_string($primitive) + ? Container::getInstance()->make($primitive) + : $primitive; + } + + protected function initializeServer(): Server + { + $server = Container::getInstance()->make( + $this->serverClass, + ['transport' => new FakeTransporter] + ); + + $server->start(); + + return $server; + } + + protected function executeRequest(Server $server, JsonRpcRequest $request): mixed + { + try { + return (fn (): iterable|JsonRpcResponse => $this->runMethodHandle($request, $this->createContext()))->call($server); + } catch (JsonRpcException $jsonRpcException) { + return $jsonRpcException->toJsonRpcResponse(); + } + } + public function actingAs(Authenticatable $user, ?string $guard = null): static { if (property_exists($user, 'wasRecentlyCreated')) { @@ -75,17 +156,11 @@ public function actingAs(Authenticatable $user, ?string $guard = null): static */ protected function run(string $method, Primitive|string $primitive, array $arguments = []): TestResponse { - $container = Container::getInstance(); - - $primitive = is_string($primitive) ? $container->make($primitive) : $primitive; - $server = $container->make($this->serverClass, ['transport' => new FakeTransporter]); - - $server->start(); - - $requestId = uniqid(); + $primitive = $this->resolvePrimitive($primitive); + $server = $this->initializeServer(); $request = new JsonRpcRequest( - $requestId, + uniqid(), $method, [ ...$primitive->toMethodCall(), @@ -93,11 +168,7 @@ protected function run(string $method, Primitive|string $primitive, array $argum ], ); - try { - $response = (fn () => $this->runMethodHandle($request, $this->createContext()))->call($server); - } catch (JsonRpcException $jsonRpcException) { - $response = $jsonRpcException->toJsonRpcResponse(); - } + $response = $this->executeRequest($server, $request); return new TestResponse($primitive, $response); } diff --git a/src/Server/Testing/TestResponse.php b/src/Server/Testing/TestResponse.php index 8a797224..56d82250 100644 --- a/src/Server/Testing/TestResponse.php +++ b/src/Server/Testing/TestResponse.php @@ -234,6 +234,56 @@ protected function isAuthenticated(?string $guard = null): bool return Container::getInstance()->make('auth')->guard($guard)->check(); } + /** + * @param array $expectedValues + */ + public function assertHasCompletions(array $expectedValues = []): static + { + $actualValues = $this->completionValues(); + + Assert::assertNotNull( + $this->response->toArray()['result']['completion'] ?? null, + 'No completion data found in response.' + ); + + foreach ($expectedValues as $expected) { + Assert::assertContains( + $expected, + $actualValues, + "Expected completion value [{$expected}] not found." + ); + } + + return $this; + } + + /** + * @param array $values + */ + public function assertCompletionValues(array $values): static + { + Assert::assertEquals( + $values, + $this->completionValues(), + 'Completion values do not match expected values.' + ); + + return $this; + } + + public function assertCompletionCount(int $count): static + { + $values = $this->completionValues(); + + Assert::assertCount( + $count, + $values, + "Expected {$count} completions, but got ".count($values) + ); + + return $this; + } + public function dd(): void { dd($this->response->toArray()); @@ -286,4 +336,14 @@ protected function errors(): array return []; } + + /** + * @return array + */ + protected function completionValues(): array + { + $response = $this->response->toArray(); + + return $response['result']['completion']['values'] ?? []; + } } diff --git a/tests/Feature/CompletionTest.php b/tests/Feature/CompletionTest.php new file mode 100644 index 00000000..c21f2c62 --- /dev/null +++ b/tests/Feature/CompletionTest.php @@ -0,0 +1,416 @@ + [], + ]; + + protected array $prompts = [ + LanguageCompletionPrompt::class, + ProjectTaskCompletionPrompt::class, + LocationPrompt::class, + UnitsPrompt::class, + StatusPrompt::class, + ]; + + protected array $resources = [ + UserFileCompletionResource::class, + ]; +} + +class LanguageCompletionPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Select a programming language'; + + public function arguments(): array + { + return [ + new Argument('language', 'Programming language', required: true), + ]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + if ($argument !== 'language') { + return CompletionResponse::empty(); + } + + $languages = ['php', 'python', 'javascript', 'typescript', 'go', 'rust']; + $matches = CompletionHelper::filterByPrefix($languages, $value); + + return CompletionResponse::match($matches); + } + + public function handle(Request $request): Response + { + return Response::text("Selected language: {$request->get('language')}"); + } +} + +class ProjectTaskCompletionPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Project and task selection'; + + public function arguments(): array + { + return [ + new Argument('projectId', 'Project ID', required: true), + new Argument('taskId', 'Task ID', required: true), + ]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return match ($argument) { + 'projectId' => CompletionResponse::match(['project-1', 'project-2', 'project-3']), + 'taskId' => $this->completeTaskId($context), + default => CompletionResponse::empty(), + }; + } + + protected function completeTaskId(array $context): CompletionResponse + { + $projectId = $context['projectId'] ?? null; + + if (! $projectId) { + return CompletionResponse::empty(); + } + + $tasks = [ + 'project-1' => ['task-1-1', 'task-1-2'], + 'project-2' => ['task-2-1', 'task-2-2'], + 'project-3' => ['task-3-1', 'task-3-2'], + ]; + + return CompletionResponse::match($tasks[$projectId] ?? []); + } + + public function handle(Request $request): Response + { + return Response::text("Project: {$request->get('projectId')}, Task: {$request->get('taskId')}"); + } +} + +class LocationPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Select a location'; + + public function arguments(): array + { + return [ + new Argument('location', 'Location name', required: true), + ]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return match ($argument) { + 'location' => CompletionResponse::match([ + 'New York', + 'Los Angeles', + 'Chicago', + 'Houston', + 'Miami', + ]), + default => CompletionResponse::empty(), + }; + } + + public function handle(Request $request): Response + { + return Response::text("Selected: {$request->get('location')}"); + } +} + +class UnitsPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Select temperature unit'; + + public function arguments(): array + { + return [ + new Argument('unit', 'Temperature unit', required: true), + ]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return match ($argument) { + 'unit' => CompletionResponse::match(TestUnits::class), + default => CompletionResponse::empty(), + }; + } + + public function handle(Request $request): Response + { + return Response::text("Unit: {$request->get('unit')}"); + } +} + +class StatusPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Select status'; + + public function arguments(): array + { + return [ + new Argument('status', 'Status value', required: true), + ]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return match ($argument) { + 'status' => CompletionResponse::match(TestStatusEnum::class), + default => CompletionResponse::empty(), + }; + } + + public function handle(Request $request): Response + { + return Response::text("Status: {$request->get('status')}"); + } +} + +class UserFileCompletionResource extends Resource implements HasUriTemplate, SupportsCompletion +{ + protected string $mimeType = 'text/plain'; + + protected string $description = 'Access user files'; + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}/files/{fileId}'); + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return match ($argument) { + 'userId' => CompletionResponse::match(['user-1', 'user-2', 'user-3']), + 'fileId' => $this->completeFileId($context), + default => CompletionResponse::empty(), + }; + } + + protected function completeFileId(array $context): CompletionResponse + { + $userId = $context['userId'] ?? null; + + if (! $userId) { + return CompletionResponse::empty(); + } + + $files = [ + 'user-1' => ['file1.txt', 'file2.txt'], + 'user-2' => ['doc1.txt', 'doc2.txt'], + 'user-3' => ['report1.txt', 'report2.txt'], + ]; + + return CompletionResponse::match($files[$userId] ?? []); + } + + public function handle(Request $request): Response + { + return Response::text("User: {$request->get('userId')}, File: {$request->get('fileId')}"); + } +} + +describe('from() - Basic Completions', function (): void { + it('filters by prefix and returns all when empty', function (): void { + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', 'py') + ->assertHasCompletions(['python']) + ->assertCompletionCount(1); + + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', '') + ->assertCompletionCount(6); + + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', 'xyz') + ->assertCompletionCount(0); + }); + + it('refines completions as user types', function (): void { + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', 'j') + ->assertHasCompletions(['javascript']) + ->assertCompletionCount(1); + + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', 'ja') + ->assertCompletionValues(['javascript']) + ->assertCompletionCount(1); + }); +}); + +describe('fromArray() - Array Completions', function (): void { + it('returns and filters locations', function (): void { + TestCompletionServer::completion(LocationPrompt::class, 'location', '') + ->assertCompletionCount(5); + + TestCompletionServer::completion(LocationPrompt::class, 'location', 'New') + ->assertHasCompletions(['New York']) + ->assertCompletionCount(1); + + TestCompletionServer::completion(LocationPrompt::class, 'location', 'los') + ->assertHasCompletions(['Los Angeles']) + ->assertCompletionCount(1); + + TestCompletionServer::completion(LocationPrompt::class, 'location', 'xyz') + ->assertCompletionCount(0); + }); +}); + +describe('fromEnum() - Enum Completions', function (): void { + it('returns backed enum values with filtering', function (): void { + TestCompletionServer::completion(UnitsPrompt::class, 'unit', '') + ->assertHasCompletions(['celsius', 'fahrenheit', 'kelvin']) + ->assertCompletionCount(3); + + TestCompletionServer::completion(UnitsPrompt::class, 'unit', 'kel') + ->assertHasCompletions(['kelvin']) + ->assertCompletionCount(1); + }); + + it('returns non-backed enum names with filtering', function (): void { + TestCompletionServer::completion(StatusPrompt::class, 'status', '') + ->assertHasCompletions(['Active', 'Inactive', 'Pending']) + ->assertCompletionCount(3); + + TestCompletionServer::completion(StatusPrompt::class, 'status', 'Pen') + ->assertHasCompletions(['Pending']) + ->assertCompletionCount(1); + }); +}); + +describe('Context-Aware Completions', function (): void { + it('completes projectId and taskId with context dependency', function (): void { + TestCompletionServer::completion(ProjectTaskCompletionPrompt::class, 'projectId', '') + ->assertHasCompletions(['project-1', 'project-2', 'project-3']) + ->assertCompletionCount(3); + + TestCompletionServer::completion(ProjectTaskCompletionPrompt::class, 'taskId', '') + ->assertCompletionCount(0); + + TestCompletionServer::completion( + ProjectTaskCompletionPrompt::class, + 'taskId', + '', + ['projectId' => 'project-1'] + ) + ->assertCompletionValues(['task-1-1', 'task-1-2']) + ->assertCompletionCount(2); + + TestCompletionServer::completion( + ProjectTaskCompletionPrompt::class, + 'taskId', + '', + ['projectId' => 'project-2'] + ) + ->assertCompletionValues(['task-2-1', 'task-2-2']) + ->assertCompletionCount(2); + }); + + it('completes userId and fileId with context dependency', function (): void { + TestCompletionServer::completion(UserFileCompletionResource::class, 'userId') + ->assertHasCompletions(['user-1', 'user-2', 'user-3']) + ->assertCompletionCount(3); + + TestCompletionServer::completion(UserFileCompletionResource::class, 'fileId') + ->assertCompletionCount(0); + + TestCompletionServer::completion( + UserFileCompletionResource::class, + 'fileId', + '', + ['userId' => 'user-1'] + ) + ->assertCompletionValues(['file1.txt', 'file2.txt']) + ->assertCompletionCount(2); + + TestCompletionServer::completion( + UserFileCompletionResource::class, + 'fileId', + '', + ['userId' => 'user-2'] + ) + ->assertCompletionValues(['doc1.txt', 'doc2.txt']) + ->assertCompletionCount(2); + }); +}); + +class RawArrayPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Raw array completion without filtering'; + + public function arguments(): array + { + return [ + new Argument('item', 'Item', required: true), + ]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + if ($argument !== 'item') { + return CompletionResponse::empty(); + } + + return CompletionResponse::result(['apple', 'apricot', 'banana']); + } + + public function handle(Request $request): Response + { + return Response::text("Item: {$request->get('item')}"); + } +} + +class ResultTestServer extends Server +{ + protected string $name = 'Result Test Server'; + + protected array $capabilities = [ + 'completions' => [], + ]; + + protected array $prompts = [ + RawArrayPrompt::class, + ]; +} + +describe('result() - Raw Completions Without Filtering', function (): void { + it('returns raw array without filtering', function (): void { + ResultTestServer::completion(RawArrayPrompt::class, 'item', 'ap') + ->assertHasCompletions(['apple', 'apricot', 'banana']) + ->assertCompletionCount(3); + + ResultTestServer::completion(RawArrayPrompt::class, 'item', 'xyz') + ->assertHasCompletions(['apple', 'apricot', 'banana']) + ->assertCompletionCount(3); + }); +}); diff --git a/tests/Unit/Completions/ArrayCompletionResponseTest.php b/tests/Unit/Completions/ArrayCompletionResponseTest.php new file mode 100644 index 00000000..40cead21 --- /dev/null +++ b/tests/Unit/Completions/ArrayCompletionResponseTest.php @@ -0,0 +1,20 @@ +resolve('py'); + + expect($resolved)->toBeInstanceOf(DirectCompletionResponse::class) + ->and($resolved->values())->toBe(['python']); +}); + +it('starts with empty values until resolved', function (): void { + $result = new ArrayCompletionResponse(['php', 'python', 'javascript']); + + expect($result->values())->toBe([]) + ->and($result->hasMore())->toBeFalse(); +}); diff --git a/tests/Unit/Completions/CompletionHelperTest.php b/tests/Unit/Completions/CompletionHelperTest.php new file mode 100644 index 00000000..d72eea12 --- /dev/null +++ b/tests/Unit/Completions/CompletionHelperTest.php @@ -0,0 +1,35 @@ +toBe(['python']); +}); + +it('returns all items when prefix is empty', function (): void { + $items = ['php', 'python', 'javascript']; + + $result = CompletionHelper::filterByPrefix($items, ''); + + expect($result)->toBe($items); +}); + +it('handles case-insensitive matching', function (): void { + $items = ['PHP', 'Python', 'JavaScript']; + + $result = CompletionHelper::filterByPrefix($items, 'py'); + + expect($result)->toBe(['Python']); +}); + +it('returns empty array when no matches', function (): void { + $items = ['php', 'python']; + + $result = CompletionHelper::filterByPrefix($items, 'rust'); + + expect($result)->toBe([]); +}); diff --git a/tests/Unit/Completions/CompletionResponseTest.php b/tests/Unit/Completions/CompletionResponseTest.php new file mode 100644 index 00000000..fb6ed4d3 --- /dev/null +++ b/tests/Unit/Completions/CompletionResponseTest.php @@ -0,0 +1,57 @@ +resolve(''); + + expect($result->values())->toBe(['php', 'python', 'javascript']) + ->and($result->hasMore())->toBeFalse(); +}); + +it('creates an empty completion result', function (): void { + $result = CompletionResponse::empty(); + + expect($result->values())->toBe([]) + ->and($result->hasMore())->toBeFalse(); +}); + +it('auto-truncates values to 100 items and sets hasMore', function (): void { + $values = array_map(fn ($i): string => "item{$i}", range(1, 150)); + $result = CompletionResponse::match($values)->resolve(''); + + expect($result->values())->toHaveCount(100) + ->and($result->hasMore())->toBeTrue(); +}); + +it('returns raw array data without filtering using a result', function (): void { + $result = CompletionResponse::result(['apple', 'apricot', 'banana'])->resolve('ap'); + + expect($result->values())->toBe(['apple', 'apricot', 'banana']) + ->and($result->hasMore())->toBeFalse(); +}); + +it('applies filtering with match but not with result', function (): void { + $items = ['apple', 'apricot', 'banana']; + + $matchResult = CompletionResponse::match($items)->resolve('ap'); + $resultResult = CompletionResponse::result($items)->resolve('ap'); + + expect($matchResult->values())->toBe(['apple', 'apricot']) + ->and($resultResult->values())->toBe(['apple', 'apricot', 'banana']); +}); + +it('returns single string as array', function (): void { + $result = CompletionResponse::result('single-value')->resolve(''); + + expect($result->values())->toBe(['single-value']) + ->and($result->hasMore())->toBeFalse(); +}); + +it('truncates raw array data to 100 items', function (): void { + $values = array_map(fn ($i): string => "item{$i}", range(1, 150)); + $result = CompletionResponse::result($values)->resolve(''); + + expect($result->values())->toHaveCount(100) + ->and($result->hasMore())->toBeTrue(); +}); diff --git a/tests/Unit/Completions/DirectCompletionResponseTest.php b/tests/Unit/Completions/DirectCompletionResponseTest.php new file mode 100644 index 00000000..7bc2f8c5 --- /dev/null +++ b/tests/Unit/Completions/DirectCompletionResponseTest.php @@ -0,0 +1,24 @@ +resolve('py'); + + expect($resolved)->toBe($result); +}); + +it('throws exception when the constructor receives more than 100 items', function (): void { + $values = array_map(fn ($i): string => "item{$i}", range(1, 101)); + + new DirectCompletionResponse($values); +})->throws(InvalidArgumentException::class, 'Completion values cannot exceed 100 items'); + +it('does not allow more than 100 items', function (): void { + $values = array_map(fn ($i): string => "item{$i}", range(1, 100)); + $result = new DirectCompletionResponse($values); + + expect($result->values())->toHaveCount(100); +}); diff --git a/tests/Unit/Completions/EnumCompletionResponseTest.php b/tests/Unit/Completions/EnumCompletionResponseTest.php new file mode 100644 index 00000000..83efa095 --- /dev/null +++ b/tests/Unit/Completions/EnumCompletionResponseTest.php @@ -0,0 +1,47 @@ +resolve(''); + + expect($resolved)->toBeInstanceOf(DirectCompletionResponse::class) + ->and($resolved->values())->toBe(['value-one', 'value-two', 'value-three']); +}); + +it('extracts non-backed enum names', function (): void { + $result = new EnumCompletionResponse(PlainEnumForTest::class); + + $resolved = $result->resolve(''); + + expect($resolved->values())->toBe(['Active', 'Inactive', 'Pending']); +}); + +it('filters enum values by prefix', function (): void { + $result = new EnumCompletionResponse(BackedEnumForTest::class); + + $resolved = $result->resolve('value-t'); + + expect($resolved->values())->toBe(['value-two', 'value-three']); +}); + +it('throws exception for invalid enum class', function (): void { + new EnumCompletionResponse('NotAnEnum'); +})->throws(InvalidArgumentException::class, 'is not an enum'); diff --git a/tests/Unit/Methods/CompletionCompleteTest.php b/tests/Unit/Methods/CompletionCompleteTest.php new file mode 100644 index 00000000..238f2288 --- /dev/null +++ b/tests/Unit/Methods/CompletionCompleteTest.php @@ -0,0 +1,390 @@ + [], + ]; + + protected array $prompts = [ + CompletionMethodTestPrompt::class, + ]; + + protected array $resources = [ + CompletionMethodTestResource::class, + ]; +} + +class CompletionMethodTestPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Test prompt'; + + public function arguments(): array + { + return [new Argument('test', 'Test arg')]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return CompletionResponse::match(['test']); + } + + public function handle(\Laravel\Mcp\Request $request): Response + { + return Response::text('test'); + } +} + +class CompletionMethodTestResource extends Resource implements SupportsCompletion +{ + protected string $uri = 'file://test'; + + protected string $mimeType = 'text/plain'; + + public function description(): string + { + return 'Test resource'; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return CompletionResponse::match(['resource-test']); + } + + public function handle(\Laravel\Mcp\Request $request): Response + { + return Response::text('test'); + } +} + +class NonCompletionPrompt extends Prompt +{ + protected string $description = 'Non-completion prompt'; + + public function handle(\Laravel\Mcp\Request $request): Response + { + return Response::text('test'); + } +} + +class CompletionMethodTestResourceWithTemplate extends Resource implements HasUriTemplate, SupportsCompletion +{ + protected string $mimeType = 'text/plain'; + + protected string $description = 'Test resource template'; + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return CompletionResponse::match(['template-test']); + } + + public function handle(\Laravel\Mcp\Request $request): Response + { + return Response::text('test'); + } +} + +it('throws an exception when completion capability is not declared', function (): void { + $server = new class(new FakeTransporter) extends Server + { + protected string $name = 'Test'; + + protected array $prompts = [CompletionMethodTestPrompt::class]; + }; + + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'completion-method-test-prompt'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Server does not support completions capability'); + +it('throws an exception when the ref is missing', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Missing required parameters: ref and argument'); + +it('throws an exception when an argument is missing', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'test'], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Missing required parameters: ref and argument'); + +it('throws an exception when the argument name is missing', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'completion-method-test-prompt'], + 'argument' => ['value' => 'test'], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Missing argument name'); + +it('throws exception for invalid reference type', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'invalid/type', 'name' => 'test'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Invalid reference type'); + +it('throws exception when prompt name is missing', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Missing [name] parameter'); + +it('throws exception when prompt not found', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'non-existent'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Prompt [non-existent] not found'); + +it('throws exception when resource URI is missing', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/resource'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Missing [uri] parameter'); + +it('throws exception when resource not found', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/resource', 'uri' => 'file://non-existent'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Resource [file://non-existent] not found'); + +it('return empty array when primitive does not support completion', function (): void { + $server = new class(new FakeTransporter) extends Server + { + protected string $name = 'Test'; + + protected array $capabilities = ['completions' => []]; + + protected array $prompts = [NonCompletionPrompt::class]; + }; + + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'non-completion-prompt'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $context = $server->createContext(); + + $response = $method->handle($request, $context); + + expect($response->toArray()) + ->toHaveKey('result') + ->and($response->toArray()['result']) + ->toHaveKey('completion') + ->and($response->toArray()['result']['completion']['values']) + ->toBe([]); + +}); + +it('completes for prompt', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'completion-method-test-prompt'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $response = $method->handle($request, $context); + + expect($response->toArray()) + ->toHaveKey('result') + ->and($response->toArray()['result']) + ->toHaveKey('completion') + ->and($response->toArray()['result']['completion']['values']) + ->toBe(['test']); +}); + +it('completes for resource', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/resource', 'uri' => 'file://test'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $response = $method->handle($request, $context); + + expect($response->toArray()) + ->toHaveKey('result') + ->and($response->toArray()['result']) + ->toHaveKey('completion') + ->and($response->toArray()['result']['completion']['values']) + ->toBe(['resource-test']); +}); + +it('finds resource by template match', function (): void { + $server = new class(new FakeTransporter) extends Server + { + protected string $name = 'Test'; + + protected array $capabilities = ['completions' => []]; + + protected array $resources = [CompletionMethodTestResourceWithTemplate::class]; + }; + + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/resource', 'uri' => 'file://users/123'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $context = $server->createContext(); + + $response = $method->handle($request, $context); + + expect($response->toArray()) + ->toHaveKey('result') + ->and($response->toArray()['result']['completion']['values']) + ->toBe(['template-test']); +}); + +it('finds resource by exact template string', function (): void { + $server = new class(new FakeTransporter) extends Server + { + protected string $name = 'Test'; + + protected array $capabilities = ['completions' => []]; + + protected array $resources = [CompletionMethodTestResourceWithTemplate::class]; + }; + + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/resource', 'uri' => 'file://users/{userId}'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $context = $server->createContext(); + + $response = $method->handle($request, $context); + + expect($response->toArray()) + ->toHaveKey('result') + ->and($response->toArray()['result']['completion']['values']) + ->toBe(['template-test']); +}); + +it('extracts and passes context arguments to completion method', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'completion-method-test-prompt'], + 'argument' => ['name' => 'test', 'value' => ''], + 'context' => [ + 'arguments' => [ + 'arg1' => 'test-value', + 'arg2' => 'another-value', + ], + ], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $response = $method->handle($request, $context); + + expect($response->toArray()) + ->toHaveKey('result') + ->and($response->toArray()['result']['completion']['values']) + ->toBe(['test']); +}); + +it('passes empty context when context is not provided', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'completion-method-test-prompt'], + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new FakeTransporter); + $context = $server->createContext(); + + $response = $method->handle($request, $context); + + expect($response->toArray()) + ->toHaveKey('result') + ->and($response->toArray()['result']['completion']['values']) + ->toBe(['test']); +});