diff --git a/src/Server.php b/src/Server.php index f3481bca..1031fdd0 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; @@ -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, ]; 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/CallbackCompletionResponse.php b/src/Server/Completions/CallbackCompletionResponse.php new file mode 100644 index 00000000..8afa3a74 --- /dev/null +++ b/src/Server/Completions/CallbackCompletionResponse.php @@ -0,0 +1,35 @@ +|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); + } +} 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..b2a9c24e --- /dev/null +++ b/src/Server/Completions/CompletionResponse.php @@ -0,0 +1,101 @@ + + */ +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)) + ); + } + } + + /** + * @param array|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 $items + */ + public static function fromArray(array $items): CompletionResponse + { + return new ArrayCompletionResponse($items); + } + + /** + * @param class-string $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 + */ + 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..61f41975 --- /dev/null +++ b/src/Server/Methods/CompletionComplete.php @@ -0,0 +1,113 @@ +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 $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 $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..c2cdfd6c --- /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/Testing/PendingTestResponse.php b/src/Server/Testing/PendingTestResponse.php index e452d091..f2860bfe 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; @@ -54,6 +55,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|\Laravel\Mcp\Server\Transport\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 +155,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 +167,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..a864303f --- /dev/null +++ b/tests/Feature/CompletionTest.php @@ -0,0 +1,247 @@ + [], + ]; + + protected array $prompts = [ + LanguageCompletionPrompt::class, + ProjectTaskCompletionPrompt::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::from($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::from(['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::from($tasks[$projectId] ?? []); + } + + public function handle(Request $request): Response + { + return Response::text("Project: {$request->get('projectId')}, Task: {$request->get('taskId')}"); + } +} + +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::from(['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::from($files[$userId] ?? []); + } + + public function handle(Request $request): Response + { + return Response::text("User: {$request->get('userId')}, File: {$request->get('fileId')}"); + } +} + +describe('Prompt Completions', function (): void { + it('completes language argument with prefix matching', function (): void { + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', 'py') + ->assertHasCompletions(['python']) + ->assertCompletionCount(1); + }); + + it('returns all languages when no prefix provided', function (): void { + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', '') + ->assertCompletionCount(6); + }); + + it('refines completions as user types', function (): void { + // Type "j" - gets java and javascript + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', 'j') + ->assertHasCompletions(['javascript']) + ->assertCompletionCount(1); + + // Type "ja" - narrows to just javascript + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', 'ja') + ->assertCompletionValues(['javascript']) + ->assertCompletionCount(1); + }); + + it('returns empty completions for non-matching prefix', function (): void { + TestCompletionServer::completion(LanguageCompletionPrompt::class, 'language', 'xyz') + ->assertCompletionCount(0); + }); +}); + +describe('Multi-Argument Completions', function (): void { + it('completes projectId', function (): void { + TestCompletionServer::completion(ProjectTaskCompletionPrompt::class, 'projectId', '') + ->assertHasCompletions(['project-1', 'project-2', 'project-3']) + ->assertCompletionCount(3); + }); + + it('returns empty taskId completions without project context', function (): void { + TestCompletionServer::completion(ProjectTaskCompletionPrompt::class, 'taskId', '') + ->assertCompletionCount(0); + }); + + it('completes taskId based on project context', function (): void { + TestCompletionServer::completion( + ProjectTaskCompletionPrompt::class, + 'taskId', + '', + ['projectId' => 'project-1'] + ) + ->assertCompletionValues(['task-1-1', 'task-1-2']) + ->assertCompletionCount(2); + }); + + it('provides different tasks for different projects', function (): void { + TestCompletionServer::completion( + ProjectTaskCompletionPrompt::class, + 'taskId', + '', + ['projectId' => 'project-2'] + ) + ->assertCompletionValues(['task-2-1', 'task-2-2']) + ->assertCompletionCount(2); + }); +}); + +describe('Resource Template Completions', function (): void { + it('completes userId', function (): void { + TestCompletionServer::completion(UserFileCompletionResource::class, 'userId', '') + ->assertHasCompletions(['user-1', 'user-2', 'user-3']) + ->assertCompletionCount(3); + }); + + it('returns empty fileId completions without user context', function (): void { + TestCompletionServer::completion(UserFileCompletionResource::class, 'fileId', '') + ->assertCompletionCount(0); + }); + + it('completes fileId based on user context', function (): void { + TestCompletionServer::completion( + UserFileCompletionResource::class, + 'fileId', + '', + ['userId' => 'user-1'] + ) + ->assertCompletionValues(['file1.txt', 'file2.txt']) + ->assertCompletionCount(2); + }); + + it('provides different files for different users', function (): void { + TestCompletionServer::completion( + UserFileCompletionResource::class, + 'fileId', + '', + ['userId' => 'user-2'] + ) + ->assertCompletionValues(['doc1.txt', 'doc2.txt']) + ->assertCompletionCount(2); + }); +}); diff --git a/tests/Feature/EnhancedCompletionTest.php b/tests/Feature/EnhancedCompletionTest.php new file mode 100644 index 00000000..43affcd9 --- /dev/null +++ b/tests/Feature/EnhancedCompletionTest.php @@ -0,0 +1,239 @@ + [], + ]; + + protected array $prompts = [ + LocationPrompt::class, + UnitsPrompt::class, + StatusPrompt::class, + DynamicPrompt::class, + SingleStringPrompt::class, + ]; +} + +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::fromArray([ + '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::fromEnum(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::fromEnum(TestStatusEnum::class), + default => CompletionResponse::empty(), + }; + } + + public function handle(Request $request): Response + { + return Response::text("Status: {$request->get('status')}"); + } +} + +class DynamicPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Dynamic completion'; + + public function arguments(): array + { + return [ + new Argument('city', 'City name', required: true), + ]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return match ($argument) { + 'city' => CompletionResponse::fromCallback(fn (string $value): \Laravel\Mcp\Server\Completions\CompletionResponse => CompletionResponse::from([ + 'San Francisco', + 'San Diego', + 'San Jose', + ])), + default => CompletionResponse::empty(), + }; + } + + public function handle(Request $request): Response + { + return Response::text("City: {$request->get('city')}"); + } +} + +class SingleStringPrompt extends Prompt implements SupportsCompletion +{ + protected string $description = 'Single string completion'; + + public function arguments(): array + { + return [ + new Argument('name', 'Name', required: true), + ]; + } + + public function complete(string $argument, string $value, array $context): CompletionResponse + { + return match ($argument) { + 'name' => CompletionResponse::fromCallback(fn (string $value): \Laravel\Mcp\Server\Completions\CompletionResponse => CompletionResponse::from('John Doe')), + default => CompletionResponse::empty(), + }; + } + + public function handle(Request $request): Response + { + return Response::text("Name: {$request->get('name')}"); + } +} + +describe('fromArray() Completions', function (): void { + it('returns all locations when no prefix provided', function (): void { + EnhancedCompletionServer::completion(LocationPrompt::class, 'location', '') + ->assertCompletionCount(5); + }); + + it('filters locations by prefix', function (): void { + EnhancedCompletionServer::completion(LocationPrompt::class, 'location', 'New') + ->assertHasCompletions(['New York']) + ->assertCompletionCount(1); + }); + + it('filters locations case-insensitively', function (): void { + EnhancedCompletionServer::completion(LocationPrompt::class, 'location', 'los') + ->assertHasCompletions(['Los Angeles']) + ->assertCompletionCount(1); + }); + + it('returns empty for non-matching prefix', function (): void { + EnhancedCompletionServer::completion(LocationPrompt::class, 'location', 'xyz') + ->assertCompletionCount(0); + }); +}); + +describe('fromEnum() Completions', function (): void { + it('returns all backed enum values', function (): void { + EnhancedCompletionServer::completion(UnitsPrompt::class, 'unit', '') + ->assertHasCompletions(['celsius', 'fahrenheit', 'kelvin']) + ->assertCompletionCount(3); + }); + + it('filters backed enum values by prefix', function (): void { + EnhancedCompletionServer::completion(UnitsPrompt::class, 'unit', 'kel') + ->assertHasCompletions(['kelvin']) + ->assertCompletionCount(1); + }); + + it('returns all non-backed enum names', function (): void { + EnhancedCompletionServer::completion(StatusPrompt::class, 'status', '') + ->assertHasCompletions(['Active', 'Inactive', 'Pending']) + ->assertCompletionCount(3); + }); + + it('filters non-backed enum names by prefix', function (): void { + EnhancedCompletionServer::completion(StatusPrompt::class, 'status', 'Pen') + ->assertHasCompletions(['Pending']) + ->assertCompletionCount(1); + }); +}); + +describe('fromCallback() Callback Completions', function (): void { + it('returns values from callback', function (): void { + EnhancedCompletionServer::completion(DynamicPrompt::class, 'city', '') + ->assertHasCompletions(['San Francisco', 'San Diego', 'San Jose']) + ->assertCompletionCount(3); + }); + + it('supports single string result', function (): void { + EnhancedCompletionServer::completion(SingleStringPrompt::class, 'name', '') + ->assertHasCompletions(['John Doe']) + ->assertCompletionCount(1); + }); +}); diff --git a/tests/Unit/Completions/CallbackCompletionResponseTest.php b/tests/Unit/Completions/CallbackCompletionResponseTest.php new file mode 100644 index 00000000..d5e6f15c --- /dev/null +++ b/tests/Unit/Completions/CallbackCompletionResponseTest.php @@ -0,0 +1,62 @@ +resolve('test-value'); + + expect($receivedValue)->toBe('test-value'); +}); + +it('handles CompletionResult return', function (): void { + $result = new CallbackCompletionResponse( + fn (string $value): CompletionResponse => CompletionResponse::from(['custom', 'result']) + ); + + $resolved = $result->resolve('test'); + + expect($resolved)->toBeInstanceOf(CompletionResponse::class) + ->and($resolved->values())->toBe(['custom', 'result']); +}); + +it('handles array return', function (): void { + $result = new CallbackCompletionResponse(fn (string $value): array => ['item1', 'item2', 'item3']); + + $resolved = $result->resolve('test'); + + expect($resolved)->toBeInstanceOf(DirectCompletionResponse::class) + ->and($resolved->values())->toBe(['item1', 'item2', 'item3']); +}); + +it('handles string return', function (): void { + $result = new CallbackCompletionResponse(fn (string $value): string => 'single-item'); + + $resolved = $result->resolve('test'); + + expect($resolved->values())->toBe(['single-item']); +}); + +it('truncates callback results to 100 items and sets hasMore', function (): void { + $result = new CallbackCompletionResponse(fn (string $value): array => array_map(fn ($i): string => "item{$i}", range(1, 150))); + + $resolved = $result->resolve(''); + + expect($resolved->values())->toHaveCount(100) + ->and($resolved->hasMore())->toBeTrue(); +}); + +it('starts with empty values until resolved', function (): void { + $result = new CallbackCompletionResponse(fn (string $value): array => ['result']); + + 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..57064050 --- /dev/null +++ b/tests/Unit/Completions/CompletionHelperTest.php @@ -0,0 +1,37 @@ +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..08a07fd6 --- /dev/null +++ b/tests/Unit/Completions/CompletionResponseTest.php @@ -0,0 +1,74 @@ +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('converts to array format', function (): void { + $result = CompletionResponse::from(['php', 'python']); + + expect($result->toArray())->toBe([ + 'values' => ['php', 'python'], + 'total' => 2, + 'hasMore' => false, + ]); +}); + +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::from($values); + + expect($result->values())->toHaveCount(100) + ->and($result->hasMore())->toBeTrue(); +}); + +it('supports single string in from', function (): void { + $result = CompletionResponse::from('single-value'); + + expect($result->values())->toBe(['single-value']); +}); + +it('fromArray creates ArrayCompletionResponse', function (): void { + $result = CompletionResponse::fromArray(['php', 'python', 'javascript']); + + expect($result)->toBeInstanceOf(ArrayCompletionResponse::class); +}); + +it('fromEnum creates EnumCompletionResponse', function (): void { + enum FactoryTestEnum: string + { + case One = 'value-one'; + } + + $result = CompletionResponse::fromEnum(FactoryTestEnum::class); + + expect($result)->toBeInstanceOf(EnumCompletionResponse::class); +}); + +it('fromCallback creates CallbackCompletionResponse', function (): void { + $result = CompletionResponse::fromCallback(fn (string $value): array => ['test']); + + expect($result)->toBeInstanceOf(CallbackCompletionResponse::class); +}); + +it('resolve returns self for direct type', function (): void { + $result = CompletionResponse::from(['php', 'python']); + $resolved = $result->resolve('py'); + + expect($resolved)->toBe($result); +}); diff --git a/tests/Unit/Completions/DirectCompletionResponseTest.php b/tests/Unit/Completions/DirectCompletionResponseTest.php new file mode 100644 index 00000000..3177334c --- /dev/null +++ b/tests/Unit/Completions/DirectCompletionResponseTest.php @@ -0,0 +1,47 @@ +resolve('py'); + + expect($resolved)->toBe($result); +}); + +it('contains provided values', function (): void { + $result = new DirectCompletionResponse(['php', 'python', 'javascript']); + + expect($result->values())->toBe(['php', 'python', 'javascript']); +}); + +it('works with hasMore flag', function (): void { + $result = new DirectCompletionResponse(['php', 'python'], hasMore: true); + + expect($result->values())->toBe(['php', 'python']) + ->and($result->hasMore())->toBeTrue(); +}); + +it('converts to array with hasMore true', function (): void { + $result = new DirectCompletionResponse(['php', 'python'], hasMore: true); + + expect($result->toArray())->toBe([ + 'values' => ['php', 'python'], + 'total' => 2, + 'hasMore' => true, + ]); +}); + +it('throws exception when 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('allows exactly 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..b44ebee8 --- /dev/null +++ b/tests/Unit/Completions/EnumCompletionResponseTest.php @@ -0,0 +1,62 @@ +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'); + +it('is case insensitive', function (): void { + $result = new EnumCompletionResponse(PlainEnumForTest::class); + + $resolved = $result->resolve('act'); + + expect($resolved->values())->toBe(['Active']); +}); + +it('starts with empty values until resolved', function (): void { + $result = new EnumCompletionResponse(BackedEnumForTest::class); + + expect($result->values())->toBe([]) + ->and($result->hasMore())->toBeFalse(); +}); diff --git a/tests/Unit/Completions/ListCompletionResponseTest.php b/tests/Unit/Completions/ListCompletionResponseTest.php new file mode 100644 index 00000000..e18e20cb --- /dev/null +++ b/tests/Unit/Completions/ListCompletionResponseTest.php @@ -0,0 +1,54 @@ +resolve('py'); + + expect($resolved)->toBeInstanceOf(DirectCompletionResponse::class) + ->and($resolved->values())->toBe(['python']); +}); + +it('returns all items when empty value', function (): void { + $result = new ArrayCompletionResponse(['php', 'python', 'javascript']); + + $resolved = $result->resolve(''); + + expect($resolved->values())->toBe(['php', 'python', 'javascript']); +}); + +it('returns empty when no match', function (): void { + $result = new ArrayCompletionResponse(['php', 'python', 'javascript']); + + $resolved = $result->resolve('rust'); + + expect($resolved->values())->toBe([]); +}); + +it('is case insensitive', function (): void { + $result = new ArrayCompletionResponse(['PHP', 'Python', 'JavaScript']); + + $resolved = $result->resolve('py'); + + expect($resolved->values())->toBe(['Python']); +}); + +it('truncates to 100 items and sets hasMore', function (): void { + $items = array_map(fn ($i): string => "item{$i}", range(1, 150)); + $result = new ArrayCompletionResponse($items); + + $resolved = $result->resolve(''); + + expect($resolved->values())->toHaveCount(100) + ->and($resolved->hasMore())->toBeTrue(); +}); + +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/Methods/CompletionCompleteTest.php b/tests/Unit/Methods/CompletionCompleteTest.php new file mode 100644 index 00000000..8e5f7171 --- /dev/null +++ b/tests/Unit/Methods/CompletionCompleteTest.php @@ -0,0 +1,380 @@ + [], + ]; + + 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::from(['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::from(['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 \Laravel\Mcp\Server\Contracts\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::from(['template-test']); + } + + public function handle(\Laravel\Mcp\Request $request): Response + { + return Response::text('test'); + } +} + +it('throws exception when completions capability is not declared', function (): void { + $server = new class(new \Laravel\Mcp\Server\Transport\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 exception when ref is missing', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'argument' => ['name' => 'test', 'value' => ''], + ]); + + $server = new CompletionMethodTestServer(new \Laravel\Mcp\Server\Transport\FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Missing required parameters: ref and argument'); + +it('throws exception when argument is missing', function (): void { + $method = new CompletionComplete; + $request = new JsonRpcRequest('1', 'completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'test'], + ]); + + $server = new CompletionMethodTestServer(new \Laravel\Mcp\Server\Transport\FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Missing required parameters: ref and argument'); + +it('throws exception when 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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\FakeTransporter); + $context = $server->createContext(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'Resource [file://non-existent] not found'); + +it('throws exception when primitive does not support completion', function (): void { + $server = new class(new \Laravel\Mcp\Server\Transport\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(); + + $method->handle($request, $context); +})->throws(JsonRpcException::class, 'does not support completion'); + +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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\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 \Laravel\Mcp\Server\Transport\FakeTransporter); + $context = $server->createContext(); + + $response = $method->handle($request, $context); + + expect($response->toArray()) + ->toHaveKey('result') + ->and($response->toArray()['result']['completion']['values']) + ->toBe(['test']); +});