From c62a1c7ea588f118461430cf9f2f1b9d4ec1dab2 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 18 Nov 2025 15:36:15 +0530 Subject: [PATCH 01/33] Add Resource Template --- .../Commands/MakeResourceTemplateCommand.php | 43 +++ src/Server.php | 2 + src/Server/McpServiceProvider.php | 3 + src/Server/Methods/ListResourceTemplates.php | 26 ++ src/Server/Methods/ListResources.php | 4 +- src/Server/Methods/ReadResource.php | 61 ++- src/Server/ResourceTemplate.php | 28 ++ src/Support/UriTemplate.php | 357 ++++++++++++++++++ stubs/resource-template.stub | 37 ++ .../Methods/ListResourceTemplatesTest.php | 164 ++++++++ tests/Unit/Methods/ListResourcesTest.php | 132 +++++++ tests/Unit/Methods/ReadResourceTest.php | 309 +++++++++++++++ tests/Unit/Resources/ResourceTemplateTest.php | 222 +++++++++++ tests/Unit/Support/UriTemplateTest.php | 283 ++++++++++++++ 14 files changed, 1655 insertions(+), 16 deletions(-) create mode 100644 src/Console/Commands/MakeResourceTemplateCommand.php create mode 100644 src/Server/Methods/ListResourceTemplates.php create mode 100644 src/Server/ResourceTemplate.php create mode 100644 src/Support/UriTemplate.php create mode 100644 stubs/resource-template.stub create mode 100644 tests/Unit/Methods/ListResourceTemplatesTest.php create mode 100644 tests/Unit/Resources/ResourceTemplateTest.php create mode 100644 tests/Unit/Support/UriTemplateTest.php diff --git a/src/Console/Commands/MakeResourceTemplateCommand.php b/src/Console/Commands/MakeResourceTemplateCommand.php new file mode 100644 index 00000000..fd444974 --- /dev/null +++ b/src/Console/Commands/MakeResourceTemplateCommand.php @@ -0,0 +1,43 @@ +laravel->basePath('stubs/resource-template.stub')) + ? $customPath + : __DIR__.'/../../../stubs/resource-template.stub'; + } + + protected function getDefaultNamespace($rootNamespace): string + { + return "{$rootNamespace}\\Mcp\\Resources"; + } + + /** + * @return array> + */ + protected function getOptions(): array + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the resource template already exists'], + ]; + } +} diff --git a/src/Server.php b/src/Server.php index 95c91102..1bd4bf63 100644 --- a/src/Server.php +++ b/src/Server.php @@ -14,6 +14,7 @@ use Laravel\Mcp\Server\Methods\Initialize; use Laravel\Mcp\Server\Methods\ListPrompts; use Laravel\Mcp\Server\Methods\ListResources; +use Laravel\Mcp\Server\Methods\ListResourceTemplates; use Laravel\Mcp\Server\Methods\ListTools; use Laravel\Mcp\Server\Methods\Ping; use Laravel\Mcp\Server\Methods\ReadResource; @@ -93,6 +94,7 @@ abstract class Server 'tools/call' => CallTool::class, 'resources/list' => ListResources::class, 'resources/read' => ReadResource::class, + 'resources/templates/list' => ListResourceTemplates::class, 'prompts/list' => ListPrompts::class, 'prompts/get' => GetPrompt::class, 'ping' => Ping::class, diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index e9972e94..ab2c21be 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -9,6 +9,7 @@ use Laravel\Mcp\Console\Commands\InspectorCommand; use Laravel\Mcp\Console\Commands\MakePromptCommand; use Laravel\Mcp\Console\Commands\MakeResourceCommand; +use Laravel\Mcp\Console\Commands\MakeResourceTemplateCommand; use Laravel\Mcp\Console\Commands\MakeServerCommand; use Laravel\Mcp\Console\Commands\MakeToolCommand; use Laravel\Mcp\Console\Commands\StartCommand; @@ -47,6 +48,7 @@ protected function registerPublishing(): void $this->publishes([ __DIR__.'/../../stubs/prompt.stub' => base_path('stubs/prompt.stub'), __DIR__.'/../../stubs/resource.stub' => base_path('stubs/resource.stub'), + __DIR__.'/../../stubs/resource-template.stub' => base_path('stubs/resource-template.stub'), __DIR__.'/../../stubs/server.stub' => base_path('stubs/server.stub'), __DIR__.'/../../stubs/tool.stub' => base_path('stubs/tool.stub'), ], 'mcp-stubs'); @@ -92,6 +94,7 @@ protected function registerCommands(): void MakeToolCommand::class, MakePromptCommand::class, MakeResourceCommand::class, + MakeResourceTemplateCommand::class, InspectorCommand::class, ]); } diff --git a/src/Server/Methods/ListResourceTemplates.php b/src/Server/Methods/ListResourceTemplates.php new file mode 100644 index 00000000..9ab21f8d --- /dev/null +++ b/src/Server/Methods/ListResourceTemplates.php @@ -0,0 +1,26 @@ +resources()->filter(fn ($resource): bool => $resource instanceof ResourceTemplate), + perPage: $context->perPage($request->get('per_page')), + cursor: $request->cursor(), + ); + + return JsonRpcResponse::result($request->id, $paginator->paginate('resourceTemplates')); + } +} diff --git a/src/Server/Methods/ListResources.php b/src/Server/Methods/ListResources.php index 5259ad6f..76fa9d26 100644 --- a/src/Server/Methods/ListResources.php +++ b/src/Server/Methods/ListResources.php @@ -6,6 +6,7 @@ use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Pagination\CursorPaginator; +use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -15,7 +16,8 @@ class ListResources implements Method public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse { $paginator = new CursorPaginator( - items: $context->resources(), + items: $context->resources() + ->filter(fn ($resource): bool => ! ($resource instanceof ResourceTemplate)), perPage: $context->perPage($request->get('per_page')), cursor: $request->cursor(), ); diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index 50718a1f..b41a85f3 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -8,11 +8,13 @@ use Illuminate\Container\Container; use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; +use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses; use Laravel\Mcp\Server\Resource; +use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -22,11 +24,6 @@ class ReadResource implements Method { use InteractsWithResponses; - /** - * @return Generator|JsonRpcResponse - * - * @throws JsonRpcException - */ public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse { if (is_null($request->get('uri'))) { @@ -37,18 +34,20 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat ); } - $resource = $context->resources() - ->first( - fn (Resource $resource): bool => $resource->uri() === $request->get('uri'), - fn () => throw new JsonRpcException( - "Resource [{$request->get('uri')}] not found.", - -32002, - $request->id, - )); + $uri = $request->get('uri'); + + $resource = $this->findResource($context->resources(), $uri); + + if (! $resource instanceof Resource) { + throw new JsonRpcException( + "Resource [{$uri}] not found.", + -32002, + $request->id, + ); + } try { - // @phpstan-ignore-next-line - $response = Container::getInstance()->call([$resource, 'handle']); + $response = $this->invokeResource($resource, $uri); } catch (ValidationException $validationException) { $response = Response::error('Invalid params: '.ValidationMessages::from($validationException)); } @@ -58,6 +57,38 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat : $this->toJsonRpcResponse($request, $response, $this->serializable($resource)); } + /** + * @param Collection $resources + */ + protected function findResource(Collection $resources, string $uri): ?Resource + { + $resource = $resources->first( + fn (Resource $r): bool => ! ($r instanceof ResourceTemplate) && $r->uri() === $uri + ); + + if ($resource) { + return $resource; + } + + return $resources + ->filter(fn (Resource $r): bool => $r instanceof ResourceTemplate) + // @phpstan-ignore-next-line + ->first(fn (ResourceTemplate $template): bool => $template->uriTemplate()->match($uri) !== null); + } + + protected function invokeResource(Resource $resource, string $uri): mixed + { + if ($resource instanceof ResourceTemplate) { + $variables = $resource->uriTemplate()->match($uri) ?? []; + $templateRequest = new Request(['uri' => $uri, ...$variables]); + + return Container::getInstance()->call($resource->handle(...), ['request' => $templateRequest]); + } + + // @phpstan-ignore-next-line + return Container::getInstance()->call([$resource, 'handle']); + } + protected function serializable(Resource $resource): callable { return fn (Collection $responses): array => [ diff --git a/src/Server/ResourceTemplate.php b/src/Server/ResourceTemplate.php new file mode 100644 index 00000000..434fa09f --- /dev/null +++ b/src/Server/ResourceTemplate.php @@ -0,0 +1,28 @@ +uriTemplate(); + } + + public function toArray(): array + { + return [ + 'name' => $this->name(), + 'title' => $this->title(), + 'description' => $this->description(), + 'uriTemplate' => (string) $this->uriTemplate(), + 'mimeType' => $this->mimeType(), + ]; + } +} diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php new file mode 100644 index 00000000..8ee3bfde --- /dev/null +++ b/src/Support/UriTemplate.php @@ -0,0 +1,357 @@ +, exploded: bool}> */ + private array $parts; + + public function __construct(string $template) + { + $this->validateLength($template, self::MAX_TEMPLATE_LENGTH, 'Template'); + $this->template = $template; + $this->parts = $this->parse($template); + } + + public static function isTemplate(string $str): bool + { + return (bool) preg_match('/\{[^}\s]+\}/', $str); + } + + /** + * @return list + */ + public function getVariableNames(): array + { + $names = []; + + foreach ($this->parts as $part) { + if (is_array($part)) { + $names = array_merge($names, $part['names']); + } + } + + return $names; + } + + /** + * @param array> $variables + */ + public function expand(array $variables): string + { + $result = ''; + $hasQueryParam = false; + + foreach ($this->parts as $part) { + if (is_string($part)) { + $result .= $part; + + continue; + } + + $expanded = $this->expandPart($part, $variables); + + if ($expanded === '') { + continue; + } + + if (($part['operator'] === '?' || $part['operator'] === '&') && $hasQueryParam) { + $result .= str_replace('?', '&', $expanded); + } else { + $result .= $expanded; + } + + if ($part['operator'] === '?' || $part['operator'] === '&') { + $hasQueryParam = true; + } + } + + return $result; + } + + /** + * @return array>|null + */ + public function match(string $uri): ?array + { + $this->validateLength($uri, self::MAX_TEMPLATE_LENGTH, 'URI'); + + $pattern = '^'; + $names = []; + + foreach ($this->parts as $part) { + if (is_string($part)) { + $pattern .= $this->escapeRegExp($part); + } else { + $patterns = $this->partToRegExp($part); + + foreach ($patterns as $patternData) { + $pattern .= $patternData['pattern']; + $names[] = ['name' => $patternData['name'], 'exploded' => $part['exploded']]; + } + } + } + + $pattern .= '$'; + + $this->validateLength($pattern, self::MAX_REGEX_LENGTH, 'Generated regex pattern'); + + if (! preg_match('#'.$pattern.'#', $uri, $matches)) { + return null; + } + + $result = []; + + foreach ($names as $i => $nameData) { + $name = $nameData['name']; + $exploded = $nameData['exploded']; + $value = $matches[$i + 1]; + $cleanName = str_replace('*', '', $name); + + $result[$cleanName] = $exploded && str_contains($value, ',') ? explode(',', $value) : $value; + } + + return $result; + } + + public function __toString(): string + { + return $this->template; + } + + private function validateLength(string $str, int $max, string $context): void + { + if (strlen($str) > $max) { + throw new InvalidArgumentException( + sprintf('%s exceeds maximum length of %d characters (got %d)', $context, $max, strlen($str)) + ); + } + } + + /** + * @return list, exploded: bool}> + */ + private function parse(string $template): array + { + $parts = []; + $currentText = ''; + $i = 0; + $expressionCount = 0; + + while ($i < strlen($template)) { + if ($template[$i] === '{') { + if ($currentText !== '') { + $parts[] = $currentText; + $currentText = ''; + } + + $end = strpos($template, '}', $i); + + if ($end === false) { + throw new InvalidArgumentException('Unclosed template expression'); + } + + $expressionCount++; + + if ($expressionCount > self::MAX_TEMPLATE_EXPRESSIONS) { + throw new InvalidArgumentException( + sprintf('Template contains too many expressions (max %d)', self::MAX_TEMPLATE_EXPRESSIONS) + ); + } + + $expr = substr($template, $i + 1, $end - $i - 1); + $operator = $this->getOperator($expr); + $exploded = str_contains($expr, '*'); + $names = $this->getNames($expr); + $name = $names[0] ?? ''; + + foreach ($names as $varName) { + $this->validateLength($varName, self::MAX_VARIABLE_LENGTH, 'Variable name'); + } + + $parts[] = [ + 'name' => $name, + 'operator' => $operator, + 'names' => $names, + 'exploded' => $exploded, + ]; + + $i = $end + 1; + } else { + $currentText .= $template[$i]; + $i++; + } + } + + if ($currentText !== '') { + $parts[] = $currentText; + } + + return $parts; + } + + private function getOperator(string $expr): string + { + foreach (self::OPERATORS as $op) { + if (str_starts_with($expr, $op)) { + return $op; + } + } + + return ''; + } + + /** + * @return list + */ + private function getNames(string $expr): array + { + $operator = $this->getOperator($expr); + $withoutOperator = substr($expr, strlen($operator)); + + $names = array_map( + fn ($name): string => str_replace('*', '', trim($name)), + explode(',', $withoutOperator) + ); + + return array_values(array_filter($names, fn ($name): bool => $name !== '')); + } + + private function encodeValue(string $value): string + { + $this->validateLength($value, self::MAX_VARIABLE_LENGTH, 'Variable value'); + + return rawurlencode($value); + } + + /** + * @param array{name: string, operator: string, names: list, exploded: bool} $part + * @param array> $variables + */ + private function expandPart(array $part, array $variables): string + { + if ($part['operator'] === '?' || $part['operator'] === '&') { + $pairs = []; + + foreach ($part['names'] as $name) { + $value = $variables[$name] ?? null; + + if ($value === null) { + continue; + } + + $encoded = is_array($value) + ? implode(',', array_map($this->encodeValue(...), $value)) + : $this->encodeValue((string) $value); + + $pairs[] = $name.'='.$encoded; + } + + if ($pairs === []) { + return ''; + } + + $separator = $part['operator'] === '?' ? '?' : '&'; + + return $separator.implode('&', $pairs); + } + + if (count($part['names']) > 1) { + $values = []; + + foreach ($part['names'] as $name) { + $value = $variables[$name] ?? null; + + if ($value !== null) { + $values[] = $value; + } + } + + if ($values === []) { + return ''; + } + + return implode(',', array_map(fn ($v): string => is_array($v) ? $v[0] : $v, $values)); + } + + $value = $variables[$part['name']] ?? null; + + if ($value === null) { + return ''; + } + + $values = is_array($value) ? $value : [$value]; + $encoded = array_map($this->encodeValue(...), $values); + + return match ($part['operator']) { + '' => implode(',', $encoded), + '+' => implode(',', $encoded), + '#' => '#'.implode(',', $encoded), + '.' => '.'.implode('.', $encoded), + '/' => '/'.implode('/', $encoded), + default => implode(',', $encoded), + }; + } + + private function escapeRegExp(string $str): string + { + return preg_quote($str, '#'); + } + + /** + * @param array{name: string, operator: string, names: list, exploded: bool} $part + * @return list + */ + private function partToRegExp(array $part): array + { + $patterns = []; + + foreach ($part['names'] as $varName) { + $this->validateLength($varName, self::MAX_VARIABLE_LENGTH, 'Variable name'); + } + + if ($part['operator'] === '?' || $part['operator'] === '&') { + foreach ($part['names'] as $i => $name) { + $prefix = $i === 0 ? '\\'.$part['operator'] : '&'; + $patterns[] = [ + 'pattern' => $prefix.$this->escapeRegExp($name).'=([^&]+)', + 'name' => $name, + ]; + } + + return $patterns; + } + + $name = $part['name']; + + $pattern = match ($part['operator']) { + '' => $part['exploded'] ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)', + '+', '#' => '(.+)', + '.' => '\\.([^/,]+)', + '/' => '/'.($part['exploded'] ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)'), + default => '([^/]+)', + }; + + $patterns[] = ['pattern' => $pattern, 'name' => $name]; + + return $patterns; + } +} diff --git a/stubs/resource-template.stub b/stubs/resource-template.stub new file mode 100644 index 00000000..886bf119 --- /dev/null +++ b/stubs/resource-template.stub @@ -0,0 +1,37 @@ +get('id'); + + return Response::text("Resource content for ID: {$id}"); + } +} diff --git a/tests/Unit/Methods/ListResourceTemplatesTest.php b/tests/Unit/Methods/ListResourceTemplatesTest.php new file mode 100644 index 00000000..f87fd31d --- /dev/null +++ b/tests/Unit/Methods/ListResourceTemplatesTest.php @@ -0,0 +1,164 @@ + '2.0', + 'id' => 1, + 'method' => 'resources/templates/list', + 'params' => [], + ]); + + $handler = new ListResourceTemplates; + $response = $handler->handle($request, $context); + $payload = $response->toArray(); + + expect($payload)->toHaveKey('result') + ->and($payload['result'])->toHaveKey('resourceTemplates') + ->and($payload['result']['resourceTemplates'])->toHaveCount(1) + ->and($payload['result']['resourceTemplates'][0])->toHaveKey('uriTemplate') + ->and($payload['result']['resourceTemplates'][0]['uriTemplate'])->toBe('file://users/{userId}/files/{fileId}'); +}); + +it('returns an empty list when no templates exist', function (): void { + $staticResource = new class extends Resource + { + protected string $uri = 'file://logs/app.log'; + + public function handle(): Response + { + return Response::text('log'); + } + }; + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 15, + tools: [], + resources: [$staticResource], + prompts: [], + ); + + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/templates/list', + 'params' => [], + ]); + + $handler = new ListResourceTemplates; + $response = $handler->handle($request, $context); + $payload = $response->toArray(); + + expect($payload['result']['resourceTemplates'])->toBeArray() + ->and($payload['result']['resourceTemplates'])->toBeEmpty(); +}); + +it('includes template metadata in listing', function (): void { + $templateResource = new class extends ResourceTemplate + { + protected string $name = 'user-file'; + + protected string $title = 'User File Resource'; + + protected string $description = 'Access user files by ID'; + + protected string $mimeType = 'application/json'; + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{id}/data'); + } + + public function handle(Request $request): Response + { + return Response::text('data'); + } + }; + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 15, + tools: [], + resources: [$templateResource], + prompts: [], + ); + + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/templates/list', + 'params' => [], + ]); + + $handler = new ListResourceTemplates; + $response = $handler->handle($request, $context); + $payload = $response->toArray(); + + $template = $payload['result']['resourceTemplates'][0]; + + expect($template['name'])->toBe('user-file') + ->and($template['title'])->toBe('User File Resource') + ->and($template['description'])->toBe('Access user files by ID') + ->and($template['uriTemplate'])->toBe('file://users/{id}/data') + ->and($template['mimeType'])->toBe('application/json'); +}); diff --git a/tests/Unit/Methods/ListResourcesTest.php b/tests/Unit/Methods/ListResourcesTest.php index fe4a792b..c1de1f5a 100644 --- a/tests/Unit/Methods/ListResourcesTest.php +++ b/tests/Unit/Methods/ListResourcesTest.php @@ -3,11 +3,14 @@ declare(strict_types=1); use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Methods\ListResources; use Laravel\Mcp\Server\Resource; +use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; +use Laravel\Mcp\Support\UriTemplate; it('returns a valid empty list resources response', function (): void { $listResources = new ListResources; @@ -135,3 +138,132 @@ public function shouldRegister(Request $request): bool 'resources' => [], ]); }); + +it('excludes resource templates from list', function (): void { + $template = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + return Response::text('test'); + } + }; + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [], + resources: [$template], + prompts: [], + ); + + $request = new JsonRpcRequest(id: 1, method: 'resources/list', params: []); + $listResources = new ListResources; + $response = $listResources->handle($request, $context); + + $payload = $response->toArray(); + + expect($payload['result'])->toEqual([ + 'resources' => [], + ]); +}); + +it('returns only static resources when both templates and static resources exist', function (): void { + $staticResource = $this->makeResource(); + + $template = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + return Response::text('test'); + } + }; + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [], + resources: [$staticResource, $template], + prompts: [], + ); + + $request = new JsonRpcRequest(id: 1, method: 'resources/list', params: []); + $listResources = new ListResources; + $response = $listResources->handle($request, $context); + + $payload = $response->toArray(); + + expect($payload['result']['resources'])->toHaveCount(1) + ->and($payload['result']['resources'][0]['name'])->toBe($staticResource->name()) + ->and($payload['result']['resources'][0]['uri'])->toBe($staticResource->uri()); +}); + +it('returns empty list when only templates are registered', function (): void { + $template1 = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + return Response::text('user'); + } + }; + + $template2 = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://posts/{postId}'); + } + + public function handle(Request $request): Response + { + return Response::text('post'); + } + }; + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [], + resources: [$template1, $template2], + prompts: [], + ); + + $request = new JsonRpcRequest(id: 1, method: 'resources/list', params: []); + $listResources = new ListResources; + $response = $listResources->handle($request, $context); + + $payload = $response->toArray(); + + expect($payload['result'])->toEqual([ + 'resources' => [], + ]); +}); diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index c8a479b1..2aaf9e88 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -2,9 +2,13 @@ declare(strict_types=1); +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\ReadResource; +use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\Transport\JsonRpcRequest; +use Laravel\Mcp\Support\UriTemplate; it('returns a valid resource result', function (): void { $resource = $this->makeResource('resource-content'); @@ -78,3 +82,308 @@ $readResource->handle($jsonRpcRequest, $context); }); + +it('reads resource template by matching URI pattern', function (): void { + $template = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + return Response::text('Template matched!'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://users/123'] + ); + + $readResource = new ReadResource; + $result = $readResource->handle($jsonRpcRequest, $context); + + $this->assertPartialMethodResult([ + 'contents' => [ + ['text' => 'Template matched!'], + ], + ], $result); +}); + +it('extracts single variable from URI and passes to handler', function (): void { + $capturedUserId = null; + + $template = new class($capturedUserId) extends ResourceTemplate + { + public function __construct(private &$capturedValue) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + $this->capturedValue = $request->get('userId'); + + return Response::text("User ID: {$this->capturedValue}"); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://users/42'] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($capturedUserId)->toBe('42'); +}); + +it('extracts multiple variables from URI and passes to handler', function (): void { + $capturedVars = null; + + $template = new class($capturedVars) extends ResourceTemplate + { + public function __construct(private &$capturedValues) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}/files/{fileId}'); + } + + public function handle(Request $request): Response + { + $this->capturedValues = [ + 'userId' => $request->get('userId'), + 'fileId' => $request->get('fileId'), + ]; + + return Response::text('test'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://users/100/files/document.pdf'] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($capturedVars)->toBe([ + 'userId' => '100', + 'fileId' => 'document.pdf', + ]); +}); + +it('includes uri parameter along with extracted variables in request', function (): void { + $capturedAll = null; + + $template = new class($capturedAll) extends ResourceTemplate + { + public function __construct(private &$capturedData) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + $this->capturedData = $request->all(); + + return Response::text('test'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $uri = 'file://users/789'; + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => $uri] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($capturedAll)->toBe([ + 'uri' => $uri, + 'userId' => '789', + ]); +}); + +it('template handler receives variables via request get method', function (): void { + $accessMethodWorks = false; + + $template = new class($accessMethodWorks) extends ResourceTemplate + { + public function __construct(private &$testResult) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://posts/{postId}/comments/{commentId}'); + } + + public function handle(Request $request): Response + { + $postId = $request->get('postId'); + $commentId = $request->get('commentId'); + + $this->testResult = ($postId === '42' && $commentId === '7'); + + return Response::text('test'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://posts/42/comments/7'] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($accessMethodWorks)->toBeTrue(); +}); + +it('tries static resources before template matching', function (): void { + $staticResource = $this->makeResource('Static resource content'); + + $template = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://resources/{resourceId}'); + } + + public function handle(Request $request): Response + { + return Response::text('Template matched!'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$staticResource, $template], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => $staticResource->uri()] + ); + + $readResource = new ReadResource; + $result = $readResource->handle($jsonRpcRequest, $context); + + $this->assertPartialMethodResult([ + 'contents' => [ + ['text' => 'Static resource content'], + ], + ], $result); +}); + +it('returns first matching template when multiple templates exist', function (): void { + $template1 = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + return Response::text('First template'); + } + }; + + $template2 = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{id}'); + } + + public function handle(Request $request): Response + { + return Response::text('Second template'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template1, $template2], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://users/42'] + ); + + $readResource = new ReadResource; + $result = $readResource->handle($jsonRpcRequest, $context); + + $this->assertPartialMethodResult([ + 'contents' => [ + ['text' => 'First template'], + ], + ], $result); +}); + +it('throws exception when URI does not match any template pattern', function (): void { + $this->expectException(JsonRpcException::class); + $this->expectExceptionMessage('Resource [file://posts/123] not found.'); + + $template = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + return Response::text('test'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://posts/123'] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); +}); diff --git a/tests/Unit/Resources/ResourceTemplateTest.php b/tests/Unit/Resources/ResourceTemplateTest.php new file mode 100644 index 00000000..d9153585 --- /dev/null +++ b/tests/Unit/Resources/ResourceTemplateTest.php @@ -0,0 +1,222 @@ +uri())->toBe('file://users/{userId}/files/{fileId}') + ->and($resource->uriTemplate()->getVariableNames())->toBe(['userId', 'fileId']) + ->and($resource)->toBeInstanceOf(ResourceTemplate::class); +}); + +it('matches URIs against a template pattern', function (): void { + $resource = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}/files/{fileId}'); + } + + public function handle(Request $request): Response + { + return Response::text('test'); + } + }; + + $matchingUri = 'file://users/123/files/document.txt'; + $nonMatchingUri = 'file://posts/123'; + + expect($resource->uriTemplate()->match($matchingUri))->not->toBeNull() + ->and($resource->uriTemplate()->match($nonMatchingUri))->toBeNull(); +}); + +it('extracts variables from matching URI', function (): void { + $resource = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}/files/{fileId}'); + } + + public function handle(Request $request): Response + { + return Response::text('test'); + } + }; + + $uri = 'file://users/42/files/test.txt'; + $variables = $resource->uriTemplate()->match($uri); + + expect($variables)->toBe([ + 'userId' => '42', + 'fileId' => 'test.txt', + ]); +}); + +it('handles template resource with extracted variables', function (): void { + $resource = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}/files/{fileId}'); + } + + public function handle(Request $request): Response + { + $userId = $request->get('userId'); + $fileId = $request->get('fileId'); + + return Response::text("User: {$userId}, File: {$fileId}"); + } + }; + + $uri = 'file://users/100/files/data.json'; + $variables = $resource->uriTemplate()->match($uri); + $request = new Request(['uri' => $uri, ...$variables]); + + $result = $resource->handle($request); + $content = $result->content()->toResource($resource); + + expect($content['text'])->toBe('User: 100, File: data.json'); +}); + +it('handles template with single variable', function (): void { + $resource = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://resource/{id}'); + } + + public function handle(Request $request): Response + { + return Response::text('test'); + } + }; + + expect($resource->uriTemplate()->getVariableNames())->toBe(['id']) + ->and($resource->uriTemplate()->match('file://resource/123'))->not->toBeNull() + ->and($resource->uriTemplate()->match('file://resource/abc'))->toBe(['id' => 'abc']); +}); + +it('handles complex URI templates with multiple path segments', function (): void { + $resource = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://organizations/{orgId}/projects/{projectId}/files/{fileId}'); + } + + public function handle(Request $request): Response + { + return Response::text('test'); + } + }; + + $uri = 'file://organizations/acme/projects/website/files/index.html'; + $variables = $resource->uriTemplate()->match($uri); + + expect($variables)->toBe([ + 'orgId' => 'acme', + 'projectId' => 'website', + 'fileId' => 'index.html', + ]); +}); + +it('does not match URIs with different path structure', function (): void { + $resource = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}/files/{fileId}'); + } + + public function handle(Request $request): Response + { + return Response::text('test'); + } + }; + + expect($resource->uriTemplate()->match('file://users/123'))->toBeNull() + ->and($resource->uriTemplate()->match('file://users/123/files/abc/extra'))->toBeNull() + ->and($resource->uriTemplate()->match('file://posts/123/files/abc'))->toBeNull(); +}); + +it('static resources do not identify as templates', function (): void { + $resource = new class extends Resource + { + protected string $uri = 'file://logs/app.log'; + + public function handle(): Response + { + return Response::text('log content'); + } + }; + + expect($resource)->not->toBeInstanceOf(ResourceTemplate::class); +}); + +it('end to end template reads uri extracts variables and returns response', function (): void { + $template = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://api/{version}/users/{userId}/posts/{postId}'); + } + + public function handle(Request $request): Response + { + $version = $request->get('version'); + $userId = $request->get('userId'); + $postId = $request->get('postId'); + $uri = $request->get('uri'); + + expect($version)->toBe('v2') + ->and($userId)->toBe('alice') + ->and($postId)->toBe('hello-world') + ->and($uri)->toBe('file://api/v2/users/alice/posts/hello-world'); + + return Response::text("API {$version}: User {$userId} - Post {$postId}"); + } + }; + + $uri = 'file://api/v2/users/alice/posts/hello-world'; + + // Extract variables from URI using template + $extractedVars = $template->uriTemplate()->match($uri); + + expect($extractedVars)->toBe([ + 'version' => 'v2', + 'userId' => 'alice', + 'postId' => 'hello-world', + ]); + + // Create request with uri and extracted variables + $request = new Request(['uri' => $uri, ...$extractedVars]); + + // Handle request (this will verify variables inside handle method) + $response = $template->handle($request); + + // Verify response content + $content = $response->content()->toResource($template); + + expect($content['text'])->toBe('API v2: User alice - Post hello-world') + ->and($template->uriTemplate()->getVariableNames())->toBe(['version', 'userId', 'postId']) + ->and($template->uri())->toBe('file://api/{version}/users/{userId}/posts/{postId}'); +}); diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php new file mode 100644 index 00000000..eab6795c --- /dev/null +++ b/tests/Unit/Support/UriTemplateTest.php @@ -0,0 +1,283 @@ +toBeTrue() + ->and(UriTemplate::isTemplate('/users/{id}'))->toBeTrue() + ->and(UriTemplate::isTemplate('http://example.com/{path}/{file}'))->toBeTrue() + ->and(UriTemplate::isTemplate('/search{?q,limit}'))->toBeTrue(); + }); + + it('should return false for strings without template expressions', function (): void { + expect(UriTemplate::isTemplate(''))->toBeFalse() + ->and(UriTemplate::isTemplate('plain string'))->toBeFalse() + ->and(UriTemplate::isTemplate('http://example.com/foo/bar'))->toBeFalse() + ->and(UriTemplate::isTemplate('{}'))->toBeFalse() + ->and(UriTemplate::isTemplate('{ }'))->toBeFalse(); + }); +}); + +describe('UriTemplate simple string expansion', function (): void { + it('should expand simple string variables', function (): void { + $template = new UriTemplate('http://example.com/users/{username}'); + expect($template->expand(['username' => 'fred']))->toBe('http://example.com/users/fred') + ->and($template->getVariableNames())->toBe(['username']); + }); + + it('should handle multiple variables', function (): void { + $template = new UriTemplate('{x,y}'); + expect($template->expand(['x' => '1024', 'y' => '768']))->toBe('1024,768') + ->and($template->getVariableNames())->toBe(['x', 'y']); + }); + + it('should encode reserved characters', function (): void { + $template = new UriTemplate('{var}'); + expect($template->expand(['var' => 'value with spaces']))->toBe('value%20with%20spaces'); + }); +}); + +describe('UriTemplate reserved expansion', function (): void { + it('should not encode reserved characters with + operator', function (): void { + $template = new UriTemplate('{+path}/here'); + expect($template->expand(['path' => '/foo/bar']))->toBe('%2Ffoo%2Fbar/here') + ->and($template->getVariableNames())->toBe(['path']); + }); +}); + +describe('UriTemplate fragment expansion', function (): void { + it('should add # prefix and not encode reserved chars', function (): void { + $template = new UriTemplate('X{#var}'); + expect($template->expand(['var' => '/test']))->toBe('X#%2Ftest') + ->and($template->getVariableNames())->toBe(['var']); + }); +}); + +describe('UriTemplate label expansion', function (): void { + it('should add . prefix', function (): void { + $template = new UriTemplate('X{.var}'); + expect($template->expand(['var' => 'test']))->toBe('X.test') + ->and($template->getVariableNames())->toBe(['var']); + }); +}); + +describe('UriTemplate path expansion', function (): void { + it('should add / prefix', function (): void { + $template = new UriTemplate('X{/var}'); + expect($template->expand(['var' => 'test']))->toBe('X/test') + ->and($template->getVariableNames())->toBe(['var']); + }); +}); + +describe('UriTemplate query expansion', function (): void { + it('should add ? prefix and name=value format', function (): void { + $template = new UriTemplate('X{?var}'); + expect($template->expand(['var' => 'test']))->toBe('X?var=test') + ->and($template->getVariableNames())->toBe(['var']); + }); +}); + +describe('UriTemplate form continuation expansion', function (): void { + it('should add & prefix and name=value format', function (): void { + $template = new UriTemplate('X{&var}'); + expect($template->expand(['var' => 'test']))->toBe('X&var=test') + ->and($template->getVariableNames())->toBe(['var']); + }); +}); + +describe('UriTemplate matching', function (): void { + it('should match simple strings and extract variables', function (): void { + $template = new UriTemplate('http://example.com/users/{username}'); + $match = $template->match('http://example.com/users/fred'); + expect($match)->toBe(['username' => 'fred']); + }); + + it('should match multiple variables', function (): void { + $template = new UriTemplate('/users/{username}/posts/{postId}'); + $match = $template->match('/users/fred/posts/123'); + expect($match)->toBe(['username' => 'fred', 'postId' => '123']); + }); + + it('should return null for non-matching URIs', function (): void { + $template = new UriTemplate('/users/{username}'); + $match = $template->match('/posts/123'); + expect($match)->toBeNull(); + }); + + it('should handle exploded arrays', function (): void { + $template = new UriTemplate('{/list*}'); + $match = $template->match('/red,green,blue'); + expect($match)->toBe(['list' => ['red', 'green', 'blue']]); + }); +}); + +describe('UriTemplate edge cases', function (): void { + it('should handle empty variables', function (): void { + $template = new UriTemplate('{empty}'); + expect($template->expand([]))->toBe('') + ->and($template->expand(['empty' => '']))->toBe(''); + }); + + it('should handle undefined variables', function (): void { + $template = new UriTemplate('{a}{b}{c}'); + expect($template->expand(['b' => '2']))->toBe('2'); + }); + + it('should handle special characters in variable names', function (): void { + $template = new UriTemplate('{$var_name}'); + expect($template->expand(['$var_name' => 'value']))->toBe('value'); + }); +}); + +describe('UriTemplate complex patterns', function (): void { + it('should handle nested path segments', function (): void { + $template = new UriTemplate('/api/{version}/{resource}/{id}'); + expect($template->expand([ + 'version' => 'v1', + 'resource' => 'users', + 'id' => '123', + ]))->toBe('/api/v1/users/123') + ->and($template->getVariableNames())->toBe(['version', 'resource', 'id']); + }); + + it('should handle query parameters with arrays', function (): void { + $template = new UriTemplate('/search{?tags*}'); + expect($template->expand([ + 'tags' => ['nodejs', 'typescript', 'testing'], + ]))->toBe('/search?tags=nodejs,typescript,testing') + ->and($template->getVariableNames())->toBe(['tags']); + }); + + it('should handle multiple query parameters', function (): void { + $template = new UriTemplate('/search{?q,page,limit}'); + expect($template->expand([ + 'q' => 'test', + 'page' => '1', + 'limit' => '10', + ]))->toBe('/search?q=test&page=1&limit=10') + ->and($template->getVariableNames())->toBe(['q', 'page', 'limit']); + }); +}); + +describe('UriTemplate matching complex patterns', function (): void { + it('should match nested path segments', function (): void { + $template = new UriTemplate('/api/{version}/{resource}/{id}'); + $match = $template->match('/api/v1/users/123'); + expect($match)->toBe([ + 'version' => 'v1', + 'resource' => 'users', + 'id' => '123', + ]) + ->and($template->getVariableNames())->toBe(['version', 'resource', 'id']); + }); + + it('should match query parameters', function (): void { + $template = new UriTemplate('/search{?q}'); + $match = $template->match('/search?q=test'); + expect($match)->toBe(['q' => 'test']) + ->and($template->getVariableNames())->toBe(['q']); + }); + + it('should match multiple query parameters', function (): void { + $template = new UriTemplate('/search{?q,page}'); + $match = $template->match('/search?q=test&page=1'); + expect($match)->toBe(['q' => 'test', 'page' => '1']) + ->and($template->getVariableNames())->toBe(['q', 'page']); + }); + + it('should handle partial matches correctly', function (): void { + $template = new UriTemplate('/users/{id}'); + expect($template->match('/users/123/extra'))->toBeNull() + ->and($template->match('/users'))->toBeNull(); + }); +}); + +describe('UriTemplate security and edge cases', function (): void { + it('should handle extremely long input strings', function (): void { + $longString = str_repeat('x', 100000); + $template = new UriTemplate('/api/{param}'); + expect($template->expand(['param' => $longString]))->toBe('/api/' . $longString) + ->and($template->match('/api/' . $longString))->toBe(['param' => $longString]); + }); + + it('should handle deeply nested template expressions', function (): void { + $template = new UriTemplate(str_repeat('{a}{b}{c}{d}{e}{f}{g}{h}{i}{j}', 1000)); + expect(fn (): string => $template->expand([ + 'a' => '1', + 'b' => '2', + 'c' => '3', + 'd' => '4', + 'e' => '5', + 'f' => '6', + 'g' => '7', + 'h' => '8', + 'i' => '9', + 'j' => '0', + ]))->not->toThrow(Exception::class); + }); + + it('should handle malformed template expressions', function (): void { + expect(fn(): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{unclosed'))->toThrow(InvalidArgumentException::class) + ->and(fn(): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{}'))->not->toThrow(Exception::class) + ->and(fn(): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{,}'))->not->toThrow(Exception::class) + ->and(fn(): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{a}{'))->toThrow(InvalidArgumentException::class); + }); + + it('should handle pathological regex patterns', function (): void { + $template = new UriTemplate('/api/{param}'); + $input = '/api/'.str_repeat('a', 100000); + expect(fn (): ?array => $template->match($input))->not->toThrow(Exception::class); + }); + + it('should handle invalid UTF-8 sequences', function (): void { + $template = new UriTemplate('/api/{param}'); + $invalidUtf8 = '���'; + expect(fn(): string => $template->expand(['param' => $invalidUtf8]))->not->toThrow(Exception::class) + ->and(fn(): ?array => $template->match('/api/' . $invalidUtf8))->not->toThrow(Exception::class); + }); + + it('should handle template/URI length mismatches', function (): void { + $template = new UriTemplate('/api/{param}'); + expect($template->match('/api/'))->toBeNull() + ->and($template->match('/api'))->toBeNull() + ->and($template->match('/api/value/extra'))->toBeNull(); + }); + + it('should handle repeated operators', function (): void { + $template = new UriTemplate('{?a}{?b}{?c}'); + expect($template->expand(['a' => '1', 'b' => '2', 'c' => '3']))->toBe('?a=1&b=2&c=3') + ->and($template->getVariableNames())->toBe(['a', 'b', 'c']); + }); + + it('should handle overlapping variable names', function (): void { + $template = new UriTemplate('{var}{vara}'); + expect($template->expand(['var' => '1', 'vara' => '2']))->toBe('12') + ->and($template->getVariableNames())->toBe(['var', 'vara']); + }); + + it('should handle empty segments', function (): void { + $template = new UriTemplate('///{a}////{b}////'); + expect($template->expand(['a' => '1', 'b' => '2']))->toBe('///1////2////') + ->and($template->match('///1////2////'))->toBe(['a' => '1', 'b' => '2']) + ->and($template->getVariableNames())->toBe(['a', 'b']); + }); + + it('should handle maximum template expression limit', function (): void { + $expressions = str_repeat('{param}', 10000); + expect(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate($expressions))->not->toThrow(Exception::class); + }); + + it('should handle maximum variable name length', function (): void { + $longName = str_repeat('a', 10000); + $template = new UriTemplate('{'.$longName.'}'); + expect(fn (): string => $template->expand([$longName => 'value']))->not->toThrow(Exception::class); + }); +}); + +describe('UriTemplate stringable', function (): void { + it('should cast to string', function (): void { + $template = new UriTemplate('/users/{id}'); + expect((string) $template)->toBe('/users/{id}'); + }); +}); From a4c8c8d93b6e814205bc64bbf108478272edde94 Mon Sep 17 00:00:00 2001 From: pushpak1300 <31663512+pushpak1300@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:00:24 +0000 Subject: [PATCH 02/33] Fix code styling --- tests/Unit/Support/UriTemplateTest.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php index eab6795c..36b1f68d 100644 --- a/tests/Unit/Support/UriTemplateTest.php +++ b/tests/Unit/Support/UriTemplateTest.php @@ -197,8 +197,8 @@ it('should handle extremely long input strings', function (): void { $longString = str_repeat('x', 100000); $template = new UriTemplate('/api/{param}'); - expect($template->expand(['param' => $longString]))->toBe('/api/' . $longString) - ->and($template->match('/api/' . $longString))->toBe(['param' => $longString]); + expect($template->expand(['param' => $longString]))->toBe('/api/'.$longString) + ->and($template->match('/api/'.$longString))->toBe(['param' => $longString]); }); it('should handle deeply nested template expressions', function (): void { @@ -218,10 +218,10 @@ }); it('should handle malformed template expressions', function (): void { - expect(fn(): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{unclosed'))->toThrow(InvalidArgumentException::class) - ->and(fn(): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{}'))->not->toThrow(Exception::class) - ->and(fn(): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{,}'))->not->toThrow(Exception::class) - ->and(fn(): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{a}{'))->toThrow(InvalidArgumentException::class); + expect(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{unclosed'))->toThrow(InvalidArgumentException::class) + ->and(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{}'))->not->toThrow(Exception::class) + ->and(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{,}'))->not->toThrow(Exception::class) + ->and(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{a}{'))->toThrow(InvalidArgumentException::class); }); it('should handle pathological regex patterns', function (): void { @@ -233,8 +233,8 @@ it('should handle invalid UTF-8 sequences', function (): void { $template = new UriTemplate('/api/{param}'); $invalidUtf8 = '���'; - expect(fn(): string => $template->expand(['param' => $invalidUtf8]))->not->toThrow(Exception::class) - ->and(fn(): ?array => $template->match('/api/' . $invalidUtf8))->not->toThrow(Exception::class); + expect(fn (): string => $template->expand(['param' => $invalidUtf8]))->not->toThrow(Exception::class) + ->and(fn (): ?array => $template->match('/api/'.$invalidUtf8))->not->toThrow(Exception::class); }); it('should handle template/URI length mismatches', function (): void { From 2940d1feaaa3fbb7c582b9f0c1b79bd2be9ab21f Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 16 Nov 2025 20:34:00 +0530 Subject: [PATCH 03/33] Add _meta support (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add meta and security scheme to tools * Fix phpstan definition * WIP 🚧 * WIP * Update the meta $property addition * Introduce `ResponseFactory` for handling responses with result-level metadata. * Replace `UnexpectedValueException` with `InvalidArgumentException` in `ResponseFactory` and improve type validation logic. * Formatting * Fix test and change the API * Refactor * Refactor * Update method signatures * Update Test * Improve testing --------- Co-authored-by: zacksmash # Conflicts: # tests/Unit/Methods/ReadResourceTest.php --- src/Request.php | 20 ++- src/Response.php | 18 ++ src/ResponseFactory.php | 68 ++++++++ src/Server/Concerns/HasMeta.php | 48 ++++++ src/Server/Content/Blob.php | 11 +- src/Server/Content/Notification.php | 11 +- src/Server/Content/Text.php | 11 +- src/Server/Contracts/Content.php | 5 + src/Server/McpServiceProvider.php | 1 + src/Server/Methods/CallTool.php | 12 +- .../Concerns/InteractsWithResponses.php | 52 ++++-- src/Server/Methods/GetPrompt.php | 10 +- src/Server/Methods/ReadResource.php | 8 +- src/Server/Primitive.php | 11 ++ src/Server/Prompt.php | 7 +- src/Server/Resource.php | 15 +- src/Server/Tool.php | 10 +- src/Server/Transport/JsonRpcRequest.php | 10 +- tests/Fixtures/PromptWithResultMetaPrompt.php | 24 +++ .../ResourceWithResultMetaResource.php | 37 ++++ tests/Fixtures/SayHiWithMetaTool.php | 42 +++++ tests/Fixtures/ToolWithBothMetaTool.php | 32 ++++ tests/Fixtures/ToolWithResultMetaTool.php | 30 ++++ tests/Unit/Content/BlobTest.php | 47 +++++ tests/Unit/Content/NotificationTest.php | 34 ++++ tests/Unit/Content/TextTest.php | 47 +++++ tests/Unit/Methods/CallToolTest.php | 162 ++++++++++++++++++ tests/Unit/Methods/GetPromptTest.php | 54 ++++++ tests/Unit/Methods/ListToolsTest.php | 55 ++++++ tests/Unit/Methods/ReadResourceTest.php | 54 ++++++ tests/Unit/Prompts/PromptTest.php | 90 ++++++++++ tests/Unit/Resources/ResourceTest.php | 45 +++++ tests/Unit/ResponseFactoryTest.php | 73 ++++++++ tests/Unit/ResponseTest.php | 55 ++++++ tests/Unit/Tools/ToolTest.php | 12 ++ tests/Unit/Transport/JsonRpcRequestTest.php | 51 ++++++ tests/Unit/Transport/JsonRpcResponseTest.php | 33 ++++ 37 files changed, 1257 insertions(+), 48 deletions(-) create mode 100644 src/ResponseFactory.php create mode 100644 src/Server/Concerns/HasMeta.php create mode 100644 tests/Fixtures/PromptWithResultMetaPrompt.php create mode 100644 tests/Fixtures/ResourceWithResultMetaResource.php create mode 100644 tests/Fixtures/SayHiWithMetaTool.php create mode 100644 tests/Fixtures/ToolWithBothMetaTool.php create mode 100644 tests/Fixtures/ToolWithResultMetaTool.php create mode 100644 tests/Unit/Prompts/PromptTest.php create mode 100644 tests/Unit/ResponseFactoryTest.php diff --git a/src/Request.php b/src/Request.php index 1c9d3e94..c2fe0ad0 100644 --- a/src/Request.php +++ b/src/Request.php @@ -24,10 +24,12 @@ class Request implements Arrayable /** * @param array $arguments + * @param array|null $meta */ public function __construct( protected array $arguments = [], - protected ?string $sessionId = null + protected ?string $sessionId = null, + protected ?array $meta = null, ) { // } @@ -92,6 +94,14 @@ public function sessionId(): ?string return $this->sessionId; } + /** + * @return array|null + */ + public function meta(): ?array + { + return $this->meta; + } + /** * @param array $arguments */ @@ -104,4 +114,12 @@ public function setSessionId(?string $sessionId): void { $this->sessionId = $sessionId; } + + /** + * @param array|null $meta + */ + public function setMeta(?array $meta): void + { + $this->meta = $meta; + } } diff --git a/src/Response.php b/src/Response.php index a64457e8..35cbae06 100644 --- a/src/Response.php +++ b/src/Response.php @@ -68,6 +68,24 @@ public function content(): Content return $this->content; } + /** + * @param Response|array $responses + */ + public static function make(Response|array $responses): ResponseFactory + { + return new ResponseFactory($responses); + } + + /** + * @param array|string $meta + */ + public function withMeta(array|string $meta, mixed $value = null): static + { + $this->content->setMeta($meta, $value); + + return $this; + } + /** * @throws NotImplementedException */ diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php new file mode 100644 index 00000000..12b89b29 --- /dev/null +++ b/src/ResponseFactory.php @@ -0,0 +1,68 @@ + + */ + protected Collection $responses; + + /** + * @param Response|array $responses + */ + public function __construct(Response|array $responses) + { + $wrapped = Arr::wrap($responses); + + foreach ($wrapped as $index => $response) { + if (! $response instanceof Response) { + throw new InvalidArgumentException( + "Invalid response type at index {$index}: Expected ".Response::class.', but received '.get_debug_type($response).'.' + ); + } + } + + $this->responses = collect($wrapped); + } + + /** + * @param string|array $meta + */ + public function withMeta(string|array $meta, mixed $value = null): static + { + $this->setMeta($meta, $value); + + return $this; + } + + /** + * @return Collection + */ + public function responses(): Collection + { + return $this->responses; + } + + /** + * @return array|null + */ + public function getMeta(): ?array + { + return $this->meta; + } +} diff --git a/src/Server/Concerns/HasMeta.php b/src/Server/Concerns/HasMeta.php new file mode 100644 index 00000000..43c83015 --- /dev/null +++ b/src/Server/Concerns/HasMeta.php @@ -0,0 +1,48 @@ +|null + */ + protected ?array $meta = null; + + /** + * @param array|string $meta + */ + public function setMeta(array|string $meta, mixed $value = null): void + { + $this->meta ??= []; + + if (! is_array($meta)) { + if (is_null($value)) { + throw new InvalidArgumentException('Value is required when using key-value signature.'); + } + + $this->meta[$meta] = $value; + + return; + } + + $this->meta = array_merge($this->meta, $meta); + } + + /** + * @template T of array + * + * @param T $baseArray + * @return T&array{_meta?: array} + */ + public function mergeMeta(array $baseArray): array + { + return ($meta = $this->meta) + ? [...$baseArray, '_meta' => $meta] + : $baseArray; + } +} diff --git a/src/Server/Content/Blob.php b/src/Server/Content/Blob.php index 5f04a1d4..f7b9f714 100644 --- a/src/Server/Content/Blob.php +++ b/src/Server/Content/Blob.php @@ -5,6 +5,7 @@ namespace Laravel\Mcp\Server\Content; use InvalidArgumentException; +use Laravel\Mcp\Server\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -12,6 +13,8 @@ class Blob implements Content { + use HasMeta; + public function __construct(protected string $content) { // @@ -42,13 +45,13 @@ public function toPrompt(Prompt $prompt): array */ public function toResource(Resource $resource): array { - return [ + return $this->mergeMeta([ 'blob' => base64_encode($this->content), 'uri' => $resource->uri(), 'name' => $resource->name(), 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), - ]; + ]); } public function __toString(): string @@ -61,9 +64,9 @@ public function __toString(): string */ public function toArray(): array { - return [ + return $this->mergeMeta([ 'type' => 'blob', 'blob' => $this->content, - ]; + ]); } } diff --git a/src/Server/Content/Notification.php b/src/Server/Content/Notification.php index 53006291..8d20566b 100644 --- a/src/Server/Content/Notification.php +++ b/src/Server/Content/Notification.php @@ -4,6 +4,7 @@ namespace Laravel\Mcp\Server\Content; +use Laravel\Mcp\Server\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -11,6 +12,8 @@ class Notification implements Content { + use HasMeta; + /** * @param array $params */ @@ -53,9 +56,15 @@ public function __toString(): string */ public function toArray(): array { + $params = $this->params; + + if ($this->meta !== null && $this->meta !== [] && ! isset($params['_meta'])) { + $params['_meta'] = $this->meta; + } + return [ 'method' => $this->method, - 'params' => $this->params, + 'params' => $params, ]; } } diff --git a/src/Server/Content/Text.php b/src/Server/Content/Text.php index c800a6f5..602d9bdb 100644 --- a/src/Server/Content/Text.php +++ b/src/Server/Content/Text.php @@ -4,6 +4,7 @@ namespace Laravel\Mcp\Server\Content; +use Laravel\Mcp\Server\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -11,6 +12,8 @@ class Text implements Content { + use HasMeta; + public function __construct(protected string $text) { // @@ -37,13 +40,13 @@ public function toPrompt(Prompt $prompt): array */ public function toResource(Resource $resource): array { - return [ + return $this->mergeMeta([ 'text' => $this->text, 'uri' => $resource->uri(), 'name' => $resource->name(), 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), - ]; + ]); } public function __toString(): string @@ -56,9 +59,9 @@ public function __toString(): string */ public function toArray(): array { - return [ + return $this->mergeMeta([ 'type' => 'text', 'text' => $this->text, - ]; + ]); } } diff --git a/src/Server/Contracts/Content.php b/src/Server/Contracts/Content.php index fa70daf9..264075c4 100644 --- a/src/Server/Contracts/Content.php +++ b/src/Server/Contracts/Content.php @@ -30,5 +30,10 @@ public function toPrompt(Prompt $prompt): array; */ public function toResource(Resource $resource): array; + /** + * @param array|string $meta + */ + public function setMeta(array|string $meta, mixed $value = null): void; + public function __toString(): string; } diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index ab2c21be..4a9de98a 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -82,6 +82,7 @@ protected function registerContainerCallbacks(): void $request->setArguments($currentRequest->all()); $request->setSessionId($currentRequest->sessionId()); + $request->setMeta($currentRequest->meta()); } }); } diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index e65bad3a..ff88581d 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -6,9 +6,9 @@ use Generator; use Illuminate\Container\Container; -use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -61,13 +61,13 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{content: array>, isError: bool} + * @return callable(ResponseFactory): array */ protected function serializable(Tool $tool): callable { - return fn (Collection $responses): array => [ - 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), - 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), - ]; + return fn (ResponseFactory $factory): array => $factory->mergeMeta([ + 'content' => $factory->responses()->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), + 'isError' => $factory->responses()->contains(fn (Response $response): bool => $response->isError()), + ]); } } diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 8e7ada3b..75e8b9fb 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -5,8 +5,10 @@ namespace Laravel\Mcp\Server\Methods\Concerns; use Generator; +use Illuminate\Support\Arr; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -16,37 +18,32 @@ trait InteractsWithResponses { /** - * @param array|Response|string $response + * @param array|Response|ResponseFactory|string $response */ - protected function toJsonRpcResponse(JsonRpcRequest $request, array|Response|string $response, callable $serializable): JsonRpcResponse + protected function toJsonRpcResponse(JsonRpcRequest $request, Response|ResponseFactory|array|string $response, callable $serializable): JsonRpcResponse { - $responses = collect( - is_array($response) ? $response : [$response] - )->map(fn (Response|string $response): Response => $response instanceof Response - ? $response - : ($this->isBinary($response) ? Response::blob($response) : Response::text($response)) - ); - - $responses->each(function (Response $response) use ($request): void { + $responseFactory = $this->toResponseFactory($response); + + $responseFactory->responses()->each(function (Response $response) use ($request): void { if (! $this instanceof Errable && $response->isError()) { throw new JsonRpcException( - // @phpstan-ignore-next-line - $response->content()->__toString(), + $response->content()->__toString(), // @phpstan-ignore-line -32603, $request->id, ); } }); - return JsonRpcResponse::result($request->id, $serializable($responses)); + return JsonRpcResponse::result($request->id, $serializable($responseFactory)); } /** - * @param iterable $responses + * @param iterable $responses * @return Generator */ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $responses, callable $serializable): Generator { + /** @var array $pendingResponses */ $pendingResponses = []; try { @@ -79,4 +76,31 @@ protected function isBinary(string $content): bool { return str_contains($content, "\0"); } + + /** + * @param array|Response|ResponseFactory|string $response + */ + private function toResponseFactory(Response|ResponseFactory|array|string $response): ResponseFactory + { + $responseFactory = is_array($response) && count($response) === 1 + ? Arr::first($response) + : $response; + + if ($responseFactory instanceof ResponseFactory) { + return $responseFactory; + } + + $responses = collect(Arr::wrap($responseFactory)) + ->map(function ($item): mixed { + if ($item instanceof Response) { + return $item; + } + + return $this->isBinary($item) + ? Response::blob($item) + : Response::text($item); + }); + + return new ResponseFactory($responses->all()); + } } diff --git a/src/Server/Methods/GetPrompt.php b/src/Server/Methods/GetPrompt.php index a9b00b85..872e2ae0 100644 --- a/src/Server/Methods/GetPrompt.php +++ b/src/Server/Methods/GetPrompt.php @@ -6,9 +6,9 @@ use Generator; use Illuminate\Container\Container; -use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; 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; @@ -59,16 +59,16 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{description?: string, messages: array}>} + * @return callable(ResponseFactory): array */ protected function serializable(Prompt $prompt): callable { - return fn (Collection $responses): array => [ + return fn (ResponseFactory $factory): array => $factory->mergeMeta([ 'description' => $prompt->description(), - 'messages' => $responses->map(fn (Response $response): array => [ + 'messages' => $factory->responses()->map(fn (Response $response): array => [ 'role' => $response->role()->value, 'content' => $response->content()->toPrompt($prompt), ])->all(), - ]; + ]); } } diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index b41a85f3..d42f845b 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -6,10 +6,10 @@ use Generator; use Illuminate\Container\Container; -use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; 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; @@ -91,8 +91,8 @@ protected function invokeResource(Resource $resource, string $uri): mixed protected function serializable(Resource $resource): callable { - return fn (Collection $responses): array => [ - 'contents' => $responses->map(fn (Response $response): array => $response->content()->toResource($resource))->all(), - ]; + return fn (ResponseFactory $factory): array => $factory->mergeMeta([ + 'contents' => $factory->responses()->map(fn (Response $response): array => $response->content()->toResource($resource))->all(), + ]); } } diff --git a/src/Server/Primitive.php b/src/Server/Primitive.php index 7a3adb12..c2b517f5 100644 --- a/src/Server/Primitive.php +++ b/src/Server/Primitive.php @@ -7,12 +7,15 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Str; +use Laravel\Mcp\Server\Concerns\HasMeta; /** * @implements Arrayable */ abstract class Primitive implements Arrayable { + use HasMeta; + protected string $name = ''; protected string $title = ''; @@ -40,6 +43,14 @@ public function description(): string : $this->description; } + /** + * @return array|null + */ + public function meta(): ?array + { + return $this->meta; + } + public function eligibleForRegistration(): bool { if (method_exists($this, 'shouldRegister')) { diff --git a/src/Server/Prompt.php b/src/Server/Prompt.php index 1e5dbd9e..eb7daa68 100644 --- a/src/Server/Prompt.php +++ b/src/Server/Prompt.php @@ -28,11 +28,12 @@ public function toMethodCall(): array } /** - * @return array{name: string, title: string, description: string, arguments: array} + * @return array{name: string, title: string, description: string, arguments: array}>} */ public function toArray(): array { - return [ + // @phpstan-ignore return.type + return $this->mergeMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), @@ -40,6 +41,6 @@ public function toArray(): array fn (Argument $argument): array => $argument->toArray(), $this->arguments(), ), - ]; + ]); } } diff --git a/src/Server/Resource.php b/src/Server/Resource.php index 26b2eac8..b65492fd 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -34,14 +34,25 @@ public function toMethodCall(): array return ['uri' => $this->uri()]; } + /** + * @return array{ + * name: string, + * title: string, + * description: string, + * uri: string, + * mimeType: string, + * _meta?: array + * } + */ public function toArray(): array { - return [ + // @phpstan-ignore return.type + return $this->mergeMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), 'uri' => $this->uri(), 'mimeType' => $this->mimeType(), - ]; + ]); } } diff --git a/src/Server/Tool.php b/src/Server/Tool.php index a9568aff..0019841e 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -51,24 +51,28 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, - * annotations?: array|object + * annotations?: array|object, + * _meta?: array * } */ public function toArray(): array { $annotations = $this->annotations(); + $schema = JsonSchema::object( $this->schema(...), )->toArray(); $schema['properties'] ??= (object) []; - return [ + // @phpstan-ignore return.type + return $this->mergeMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), 'inputSchema' => $schema, 'annotations' => $annotations === [] ? (object) [] : $annotations, - ]; + ]); + } } diff --git a/src/Server/Transport/JsonRpcRequest.php b/src/Server/Transport/JsonRpcRequest.php index 1ee32878..9d1ebe43 100644 --- a/src/Server/Transport/JsonRpcRequest.php +++ b/src/Server/Transport/JsonRpcRequest.php @@ -60,8 +60,16 @@ public function get(string $key, mixed $default = null): mixed return $this->params[$key] ?? $default; } + /** + * @return array|null + */ + public function meta(): ?array + { + return isset($this->params['_meta']) && is_array($this->params['_meta']) ? $this->params['_meta'] : null; + } + public function toRequest(): Request { - return new Request($this->params['arguments'] ?? [], $this->sessionId); + return new Request($this->params['arguments'] ?? [], $this->sessionId, $this->meta()); } } diff --git a/tests/Fixtures/PromptWithResultMetaPrompt.php b/tests/Fixtures/PromptWithResultMetaPrompt.php new file mode 100644 index 00000000..4ee21472 --- /dev/null +++ b/tests/Fixtures/PromptWithResultMetaPrompt.php @@ -0,0 +1,24 @@ +withMeta(['key' => 'value']) + )->withMeta([ + 'prompt_version' => '2.0', + 'last_updated' => '2025-01-01', + ]); + } +} diff --git a/tests/Fixtures/ResourceWithResultMetaResource.php b/tests/Fixtures/ResourceWithResultMetaResource.php new file mode 100644 index 00000000..b7460b0e --- /dev/null +++ b/tests/Fixtures/ResourceWithResultMetaResource.php @@ -0,0 +1,37 @@ +withMeta([ + 'last_modified' => '2025-01-01', + 'version' => '1.0', + ]); + } + + public function uri(): string + { + return 'file://resources/with-result-meta.txt'; + } + + public function mimeType(): string + { + return 'text/plain'; + } +} diff --git a/tests/Fixtures/SayHiWithMetaTool.php b/tests/Fixtures/SayHiWithMetaTool.php new file mode 100644 index 00000000..8e0b4e1f --- /dev/null +++ b/tests/Fixtures/SayHiWithMetaTool.php @@ -0,0 +1,42 @@ + 'abc-123', + 'source' => 'tests/fixtures', + ]; + + public function handle(Request $request): Response + { + $request->validate([ + 'name' => 'required|string', + ]); + + $name = $request->get('name'); + + return Response::text('Hello, '.$name.'!')->withMeta([ + 'test' => 'metadata', + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string() + ->description('The name of the person to greet') + ->required(), + ]; + } +} diff --git a/tests/Fixtures/ToolWithBothMetaTool.php b/tests/Fixtures/ToolWithBothMetaTool.php new file mode 100644 index 00000000..8ab333dd --- /dev/null +++ b/tests/Fixtures/ToolWithBothMetaTool.php @@ -0,0 +1,32 @@ +withMeta(['content_index' => 1]), + Response::text('Second response')->withMeta(['content_index' => 2]), + ])->withMeta([ + 'result_key' => 'result_value', + 'total_responses' => 2, + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Fixtures/ToolWithResultMetaTool.php b/tests/Fixtures/ToolWithResultMetaTool.php new file mode 100644 index 00000000..1b975940 --- /dev/null +++ b/tests/Fixtures/ToolWithResultMetaTool.php @@ -0,0 +1,30 @@ +withMeta([ + 'session_id' => 50, + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Unit/Content/BlobTest.php b/tests/Unit/Content/BlobTest.php index 7d7823e2..92546ea5 100644 --- a/tests/Unit/Content/BlobTest.php +++ b/tests/Unit/Content/BlobTest.php @@ -29,6 +29,33 @@ ]); }); +it('preserves meta when converting to a resource payload', function (): void { + $blob = new Blob('raw-bytes'); + $blob->setMeta(['encoding' => 'base64']); + + $resource = new class extends Resource + { + protected string $uri = 'file://avatar.png'; + + protected string $name = 'avatar'; + + protected string $title = 'User Avatar'; + + protected string $mimeType = 'image/png'; + }; + + $payload = $blob->toResource($resource); + + expect($payload)->toMatchArray([ + 'blob' => base64_encode('raw-bytes'), + 'uri' => 'file://avatar.png', + 'name' => 'avatar', + 'title' => 'User Avatar', + 'mimeType' => 'image/png', + '_meta' => ['encoding' => 'base64'], + ]); +}); + it('throws when used in tools', function (): void { $blob = new Blob('anything'); @@ -55,3 +82,23 @@ 'blob' => 'bytes', ]); }); + +it('supports meta via setMeta', function (): void { + $blob = new Blob('binary-data'); + $blob->setMeta(['encoding' => 'base64']); + + expect($blob->toArray())->toMatchArray([ + 'type' => 'blob', + 'blob' => 'binary-data', + '_meta' => ['encoding' => 'base64'], + ]); +}); + +it('does not include meta if null', function (): void { + $blob = new Blob('data'); + + expect($blob->toArray())->toMatchArray([ + 'type' => 'blob', + 'blob' => 'data', + ]); +}); diff --git a/tests/Unit/Content/NotificationTest.php b/tests/Unit/Content/NotificationTest.php index 0a2ad155..227114d0 100644 --- a/tests/Unit/Content/NotificationTest.php +++ b/tests/Unit/Content/NotificationTest.php @@ -62,3 +62,37 @@ 'params' => ['x' => 1, 'y' => 2], ]); }); + +it('supports _meta via setMeta', function (): void { + $notification = new Notification('test/event', ['data' => 'value']); + $notification->setMeta(['author' => 'system']); + + expect($notification->toArray())->toMatchArray([ + 'method' => 'test/event', + 'params' => [ + 'data' => 'value', + '_meta' => ['author' => 'system'], + ], + ]); +}); + +it('supports _meta in params', function (): void { + $notification = new Notification('test/event', [ + 'data' => 'value', + '_meta' => ['source' => 'params'], + ]); + + expect($notification->toArray())->toMatchArray([ + 'method' => 'test/event', + 'params' => [ + 'data' => 'value', + '_meta' => ['source' => 'params'], + ], + ]); +}); + +it('does not include _meta if not set', function (): void { + $notification = new Notification('test/event', ['data' => 'value']); + + expect($notification->toArray()['params'])->not->toHaveKey('_meta'); +}); diff --git a/tests/Unit/Content/TextTest.php b/tests/Unit/Content/TextTest.php index ade98a93..95a0f6e1 100644 --- a/tests/Unit/Content/TextTest.php +++ b/tests/Unit/Content/TextTest.php @@ -29,6 +29,33 @@ ]); }); +it('preserves meta when converting to a resource payload', function (): void { + $text = new Text('Hello world'); + $text->setMeta(['author' => 'John']); + + $resource = new class extends Resource + { + protected string $uri = 'file://readme.txt'; + + protected string $name = 'readme'; + + protected string $title = 'Readme File'; + + protected string $mimeType = 'text/plain'; + }; + + $payload = $text->toResource($resource); + + expect($payload)->toEqual([ + 'text' => 'Hello world', + 'uri' => 'file://readme.txt', + 'name' => 'readme', + 'title' => 'Readme File', + 'mimeType' => 'text/plain', + '_meta' => ['author' => 'John'], + ]); +}); + it('may be used in tools', function (): void { $text = new Text('Run me'); @@ -65,3 +92,23 @@ 'text' => 'abc', ]); }); + +it('supports meta via setMeta', function (): void { + $text = new Text('Hello'); + $text->setMeta(['author' => 'John']); + + expect($text->toArray())->toEqual([ + 'type' => 'text', + 'text' => 'Hello', + '_meta' => ['author' => 'John'], + ]); +}); + +it('does not include meta if null', function (): void { + $text = new Text('Hello'); + + expect($text->toArray())->toEqual([ + 'type' => 'text', + 'text' => 'Hello', + ]); +}); diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index d8c72c47..0536e3d2 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -7,6 +7,9 @@ use Tests\Fixtures\CurrentTimeTool; use Tests\Fixtures\SayHiTool; use Tests\Fixtures\SayHiTwiceTool; +use Tests\Fixtures\SayHiWithMetaTool; +use Tests\Fixtures\ToolWithBothMetaTool; +use Tests\Fixtures\ToolWithResultMetaTool; it('returns a valid call tool response', function (): void { $request = JsonRpcRequest::from([ @@ -145,6 +148,55 @@ ]); }); +it('includes result meta when responses provide it', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'say-hi-with-meta-tool', + 'arguments' => ['name' => 'John Doe'], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [SayHiWithMetaTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + $payload = $response->toArray(); + + expect($payload) + ->toMatchArray([ + 'id' => 1, + 'result' => [ + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Hello, John Doe!', + '_meta' => [ + 'test' => 'metadata', + ], + ], + ], + 'isError' => false, + ], + ]); +}); + it('may resolve dependencies out of the container', function (): void { $request = JsonRpcRequest::from([ 'jsonrpc' => '2.0', @@ -182,3 +234,113 @@ ->and($type)->toEqual('text') ->and($text)->toContain('The current time is '); }); + +it('returns a result with result-level meta when using ResponseFactory', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'tool-with-result-meta-tool', + 'arguments' => [], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [ToolWithResultMetaTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload) + ->toMatchArray([ + 'id' => 1, + 'result' => [ + '_meta' => [ + 'session_id' => 50, + ], + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Tool response with result meta', + ], + ], + 'isError' => false, + ], + ]) + ->and($payload['result']['_meta']) + ->toHaveKeys(['session_id']); +}); + +it('separates content-level meta from result-level meta', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'tool-with-both-meta-tool', + 'arguments' => [], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [ToolWithBothMetaTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload) + ->toMatchArray([ + 'result' => [ + 'isError' => false, + '_meta' => [ + 'result_key' => 'result_value', + 'total_responses' => 2, + ], + 'content' => [ + [ + 'type' => 'text', + 'text' => 'First response', + '_meta' => ['content_index' => 1], + ], + [ + 'type' => 'text', + 'text' => 'Second response', + '_meta' => ['content_index' => 2], + ], + ], + ], + ]); +}); diff --git a/tests/Unit/Methods/GetPromptTest.php b/tests/Unit/Methods/GetPromptTest.php index 62ace7e6..f086b97b 100644 --- a/tests/Unit/Methods/GetPromptTest.php +++ b/tests/Unit/Methods/GetPromptTest.php @@ -5,6 +5,7 @@ use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; +use Tests\Fixtures\PromptWithResultMetaPrompt; use Tests\Fixtures\ReviewMyCodePrompt; use Tests\Fixtures\TellMeHiPrompt; @@ -195,3 +196,56 @@ expect($payload['result'])->toHaveKey('description'); expect($payload['result'])->toHaveKey('messages'); }); + +it('returns a prompt result with result-level meta when using ResponseFactory', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/get', + 'params' => [ + 'name' => 'prompt-with-result-meta-prompt', + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [PromptWithResultMetaPrompt::class], + ); + + $method = new GetPrompt; + + $response = $method->handle($request, $context); + + $payload = $response->toArray(); + + expect($payload) + ->toBeArray() + ->id->toBe(1) + ->result->toMatchArray([ + '_meta' => [ + 'prompt_version' => '2.0', + 'last_updated' => '2025-01-01', + ], + 'description' => 'Prompt with result-level meta', + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'text', + 'text' => 'Prompt instructions with result meta', + '_meta' => [ + 'key' => 'value', + ], + ], + ], + ], + ]); +}); diff --git a/tests/Unit/Methods/ListToolsTest.php b/tests/Unit/Methods/ListToolsTest.php index 027a742e..9c55e7f6 100644 --- a/tests/Unit/Methods/ListToolsTest.php +++ b/tests/Unit/Methods/ListToolsTest.php @@ -6,6 +6,7 @@ use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Tests\Fixtures\SayHiTool; +use Tests\Fixtures\SayHiWithMetaTool; if (! class_exists('Tests\\Unit\\Methods\\DummyTool1')) { for ($i = 1; $i <= 12; $i++) { @@ -350,3 +351,57 @@ public function shouldRegister(Request $request): bool 'tools' => [], ]); }); + +it('includes meta in tool response when tool has meta property', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'list-tools', + 'params' => [], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [SayHiWithMetaTool::class], + resources: [], + prompts: [], + ); + + $listTools = new ListTools; + + $response = $listTools->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + $payload = $response->toArray(); + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toEqual([ + 'tools' => [ + [ + 'name' => 'say-hi-with-meta-tool', + 'title' => 'Say Hi With Meta Tool', + 'description' => 'This tool says hello to a person with metadata', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the person to greet', + ], + ], + 'required' => ['name'], + ], + 'annotations' => (object) [], + '_meta' => [ + 'requestId' => 'abc-123', + 'source' => 'tests/fixtures', + ], + ], + ], + ]); +}); diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index 2aaf9e88..c92e6a5a 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -6,8 +6,11 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\ReadResource; +use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\Transport\JsonRpcRequest; +use Laravel\Mcp\Server\Transport\JsonRpcResponse; +use Tests\Fixtures\ResourceWithResultMetaResource; use Laravel\Mcp\Support\UriTemplate; it('returns a valid resource result', function (): void { @@ -387,3 +390,54 @@ public function handle(Request $request): Response $readResource = new ReadResource; $readResource->handle($jsonRpcRequest, $context); }); + +it('returns resource result with result-level meta when using ResponseFactory', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'file://resources/with-result-meta.txt', + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [ResourceWithResultMetaResource::class], + prompts: [], + ); + + $method = new ReadResource; + + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload)->toMatchArray([ + 'result' => [ + '_meta' => [ + 'last_modified' => '2025-01-01', + 'version' => '1.0', + ], + 'contents' => [ + [ + 'text' => 'Resource content with result meta', + 'uri' => 'file://resources/with-result-meta.txt', + 'name' => 'resource-with-result-meta-resource', + 'title' => 'Resource With Result Meta Resource', + 'mimeType' => 'text/plain', + ], + ], + ], + ]); +}); diff --git a/tests/Unit/Prompts/PromptTest.php b/tests/Unit/Prompts/PromptTest.php new file mode 100644 index 00000000..93571933 --- /dev/null +++ b/tests/Unit/Prompts/PromptTest.php @@ -0,0 +1,90 @@ +meta())->toBeNull() + ->and($prompt->toArray())->not->toHaveKey('_meta'); +}); + +it('can have custom meta', function (): void { + $prompt = new class extends Prompt + { + protected ?array $meta = [ + 'category' => 'greeting', + 'tags' => ['hello', 'welcome'], + ]; + + public function description(): string + { + return 'Test prompt'; + } + + public function handle(): Response + { + return Response::text('Hello'); + } + }; + + expect($prompt->toArray()) + ->toHaveKey('_meta') + ->_meta->toEqual([ + 'category' => 'greeting', + 'tags' => ['hello', 'welcome'], + ]); +}); + +it('includes meta in array representation with other fields', function (): void { + $prompt = new class extends Prompt + { + protected string $name = 'greet'; + + protected string $title = 'Greeting Prompt'; + + protected string $description = 'A friendly greeting'; + + protected ?array $meta = [ + 'version' => '1.0', + ]; + + public function handle(): Response + { + return Response::text('Hello'); + } + + public function arguments(): array + { + return [ + new Argument('name', 'User name', true), + ]; + } + }; + + $array = $prompt->toArray(); + + expect($array) + ->toHaveKey('name', 'greet') + ->toHaveKey('title', 'Greeting Prompt') + ->toHaveKey('description', 'A friendly greeting') + ->toHaveKey('arguments') + ->toHaveKey('_meta') + ->and($array) + ->_meta->toEqual(['version' => '1.0']) + ->arguments->toHaveCount(1); + +}); diff --git a/tests/Unit/Resources/ResourceTest.php b/tests/Unit/Resources/ResourceTest.php index 9f812743..b4b3e439 100644 --- a/tests/Unit/Resources/ResourceTest.php +++ b/tests/Unit/Resources/ResourceTest.php @@ -141,3 +141,48 @@ public function handle(): string }; expect($resource->description())->toBe('A test resource.'); }); + +it('returns no meta by default', function (): void { + $resource = new class extends Resource + { + public function description(): string + { + return 'Test resource'; + } + + public function handle(): string + { + return 'Content'; + } + }; + + expect($resource->meta())->toBeNull() + ->and($resource->toArray())->not->toHaveKey('_meta'); +}); + +it('can have custom meta', function (): void { + $resource = new class extends Resource + { + protected ?array $meta = [ + 'author' => 'John Doe', + 'version' => '1.0', + ]; + + public function description(): string + { + return 'Test resource'; + } + + public function handle(): string + { + return 'Content'; + } + }; + + expect($resource->toArray()) + ->toHaveKey('_meta') + ->_meta->toEqual([ + 'author' => 'John Doe', + 'version' => '1.0', + ]); +}); diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php new file mode 100644 index 00000000..55b67407 --- /dev/null +++ b/tests/Unit/ResponseFactoryTest.php @@ -0,0 +1,73 @@ +toBeInstanceOf(ResponseFactory::class); + expect($factory->responses()) + ->toHaveCount(1) + ->first()->toBe($response); +}); + +it('creates a factory with multiple responses', function (): void { + $response1 = Response::text('First'); + $response2 = Response::text('Second'); + $factory = new ResponseFactory([$response1, $response2]); + + expect($factory)->toBeInstanceOf(ResponseFactory::class); + expect($factory->responses()) + ->toHaveCount(2) + ->first()->toBe($response1); + expect($factory->responses()->last())->toBe($response2); +}); + +it('supports fluent withMeta for result-level metadata', function (): void { + $factory = (new ResponseFactory(Response::text('Hello'))) + ->withMeta(['key' => 'value']); + + expect($factory->getMeta())->toEqual(['key' => 'value']); +}); + +it('supports withMeta with key-value signature', function (): void { + $factory = (new ResponseFactory(Response::text('Hello'))) + ->withMeta('key1', 'value1') + ->withMeta('key2', 'value2'); + + expect($factory->getMeta())->toEqual(['key1' => 'value1', 'key2' => 'value2']); +}); + +it('merges multiple withMeta calls', function (): void { + $factory = (new ResponseFactory(Response::text('Hello'))) + ->withMeta(['key1' => 'value1']) + ->withMeta(['key2' => 'value1']) + ->withMeta(['key2' => 'value2']); + + expect($factory->getMeta())->toEqual(['key1' => 'value1', 'key2' => 'value2']); +}); + +it('supports Conditionable trait', function (): void { + $factory = (new ResponseFactory(Response::text('Hello'))) + ->when(true, fn ($f): ResponseFactory => $f->withMeta(['conditional' => 'yes'])); + + expect($factory->getMeta())->toEqual(['conditional' => 'yes']); +}); + +it('supports unless from Conditionable trait', function (): void { + $factory = (new ResponseFactory(Response::text('Hello'))) + ->unless(false, fn ($f): ResponseFactory => $f->withMeta(['unless' => 'applied'])); + + expect($factory->getMeta())->toEqual(['unless' => 'applied']); +}); + +it('separates content-level meta from result-level meta', function (): void { + $response = Response::text('Hello')->withMeta(['content_meta' => 'content_value']); + $factory = (new ResponseFactory($response)) + ->withMeta(['result_meta' => 'result_value']); + + expect($factory->getMeta())->toEqual(['result_meta' => 'result_value']) + ->and($factory->responses()->first())->toBe($response); +}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index d9484d7a..6f206248 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -5,6 +5,7 @@ use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Content\Blob; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; @@ -122,3 +123,57 @@ $content = $response->content(); expect((string) $content)->toBe(json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); }); + +it('creates text response with content meta', function (): void { + $response = Response::text('Hello')->withMeta(['author' => 'John']); + + expect($response->content())->toBeInstanceOf(Text::class) + ->and($response->content()->toArray())->toHaveKey('_meta') + ->and($response->content()->toArray()['_meta'])->toEqual(['author' => 'John']); +}); + +it('creates blob response with content meta', function (): void { + $response = Response::blob('binary')->withMeta(['encoding' => 'utf-8']); + + expect($response->content())->toBeInstanceOf(Blob::class) + ->and($response->content()->toArray())->toHaveKey('_meta') + ->and($response->content()->toArray()['_meta'])->toEqual(['encoding' => 'utf-8']); +}); + +it('creates a notification response with content meta', function (): void { + $response = Response::notification('test/event', ['data' => 'value'])->withMeta(['author' => 'system']); + + expect($response->content())->toBeInstanceOf(Notification::class) + ->and($response->content()->toArray()['params'])->toHaveKey('_meta') + ->and($response->content()->toArray()['params']['_meta'])->toEqual(['author' => 'system']); +}); + +it('throws exception when array contains a non-Response object', function (): void { + expect(fn (): ResponseFactory => Response::make([ + Response::text('Valid'), + 'Invalid string', + ]))->toThrow( + InvalidArgumentException::class, + ); +}); + +it('throws exception when array contains nested ResponseFactory', function (): void { + $nestedFactory = Response::make(Response::text('Nested')); + + expect(fn (): ResponseFactory => Response::make([ + Response::text('First'), + $nestedFactory, + Response::text('Third'), + ]))->toThrow( + InvalidArgumentException::class, + ); +}); + +it('throws exception when an array contains null', function (): void { + expect(fn (): ResponseFactory => Response::make([ + Response::text('Valid'), + null, + ]))->toThrow( + InvalidArgumentException::class, + ); +}); diff --git a/tests/Unit/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index c30c158d..c83cd313 100644 --- a/tests/Unit/Tools/ToolTest.php +++ b/tests/Unit/Tools/ToolTest.php @@ -94,6 +94,11 @@ ->and($array['inputSchema']['required'])->toEqual(['message']); }); +it('can have custom meta', function (): void { + $tool = new CustomMetaTool; + expect($tool->toArray()['_meta'])->toEqual(['key' => 'value']); +}); + class TestTool extends Tool { public function description(): string @@ -155,3 +160,10 @@ public function schema(\Illuminate\JsonSchema\JsonSchema $schema): array ]; } } + +class CustomMetaTool extends TestTool +{ + protected ?array $meta = [ + 'key' => 'value', + ]; +} diff --git a/tests/Unit/Transport/JsonRpcRequestTest.php b/tests/Unit/Transport/JsonRpcRequestTest.php index e234f4b7..3ceae3f5 100644 --- a/tests/Unit/Transport/JsonRpcRequestTest.php +++ b/tests/Unit/Transport/JsonRpcRequestTest.php @@ -113,3 +113,54 @@ expect($requestWithCursor->cursor())->toEqual('CUR123') ->and($requestWithCursor->get('foo'))->toEqual('bar'); }); + +it('extracts _meta from params', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'echo', + '_meta' => [ + 'progressToken' => 'token-123', + 'customKey' => 'customValue', + ], + ], + ]); + + expect($request->meta())->toEqual([ + 'progressToken' => 'token-123', + 'customKey' => 'customValue', + ]) + ->and($request->params)->toHaveKey('_meta') + ->and($request->params)->toHaveKey('name', 'echo'); +}); + +it('has null meta when not provided', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'echo', + ], + ]); + + expect($request->meta())->toBeNull(); +}); + +it('passes meta to Request object', function (): void { + $jsonRpcRequest = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'arguments' => ['message' => 'Hello'], + '_meta' => ['requestId' => '456'], + ], + ]); + + $request = $jsonRpcRequest->toRequest(); + + expect($request->meta())->toEqual(['requestId' => '456']); +}); diff --git a/tests/Unit/Transport/JsonRpcResponseTest.php b/tests/Unit/Transport/JsonRpcResponseTest.php index 1deb19e0..e8489189 100644 --- a/tests/Unit/Transport/JsonRpcResponseTest.php +++ b/tests/Unit/Transport/JsonRpcResponseTest.php @@ -37,3 +37,36 @@ expect($response->toJson())->toEqual($expectedJson); }); + +it('includes _meta in result when provided in result array', function (): void { + $response = JsonRpcResponse::result( + 1, + [ + 'content' => 'Hello', + '_meta' => [ + 'requestId' => '123', + 'timestamp' => 1234567890, + ], + ] + ); + + $expectedArray = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'content' => 'Hello', + '_meta' => [ + 'requestId' => '123', + 'timestamp' => 1234567890, + ], + ], + ]; + + expect($response->toArray())->toEqual($expectedArray); +}); + +it('does not include _meta when not in result', function (): void { + $response = JsonRpcResponse::result(1, ['content' => 'Hello']); + + expect($response->toArray()['result'])->not->toHaveKey('_meta'); +}); From b631f69f3f8a4ac5bf4490e99fbd7561f6f3a711 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 18 Nov 2025 20:11:05 +0530 Subject: [PATCH 04/33] Remove non-spec fields from resource content responses (#110) * Remove non-spec fields from resource content responses * Update the minimum code coverage threshold to 91.7% --- composer.json | 2 +- src/Server/Content/Blob.php | 2 -- src/Server/Content/Text.php | 2 -- tests/Pest.php | 2 -- tests/Unit/Content/BlobTest.php | 4 ---- tests/Unit/Content/TextTest.php | 4 ---- tests/Unit/Methods/ReadResourceTest.php | 2 -- tests/Unit/Resources/ResourceTest.php | 8 -------- 8 files changed, 1 insertion(+), 25 deletions(-) diff --git a/composer.json b/composer.json index be814e58..b4ee54b4 100644 --- a/composer.json +++ b/composer.json @@ -80,7 +80,7 @@ "pint --test", "rector --dry-run" ], - "test:unit": "pest --ci --coverage --min=91.4", + "test:unit": "pest --ci --coverage --min=91.7", "test:types": "phpstan", "test": [ "@test:lint", diff --git a/src/Server/Content/Blob.php b/src/Server/Content/Blob.php index f7b9f714..a02d5760 100644 --- a/src/Server/Content/Blob.php +++ b/src/Server/Content/Blob.php @@ -48,8 +48,6 @@ public function toResource(Resource $resource): array return $this->mergeMeta([ 'blob' => base64_encode($this->content), 'uri' => $resource->uri(), - 'name' => $resource->name(), - 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), ]); } diff --git a/src/Server/Content/Text.php b/src/Server/Content/Text.php index 602d9bdb..5b5b9e28 100644 --- a/src/Server/Content/Text.php +++ b/src/Server/Content/Text.php @@ -43,8 +43,6 @@ public function toResource(Resource $resource): array return $this->mergeMeta([ 'text' => $this->text, 'uri' => $resource->uri(), - 'name' => $resource->name(), - 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), ]); } diff --git a/tests/Pest.php b/tests/Pest.php index 3f557463..3c875ae1 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -211,9 +211,7 @@ function expectedReadResourceResponse(): array 'contents' => [[ 'text' => '2025-07-02 12:00:00 Error: Something went wrong.', 'uri' => 'file://resources/last-log-line-resource', - 'title' => 'Last Log Line Resource', 'mimeType' => 'text/plain', - 'name' => 'last-log-line-resource', ]], ], ]; diff --git a/tests/Unit/Content/BlobTest.php b/tests/Unit/Content/BlobTest.php index 92546ea5..fa6e85ee 100644 --- a/tests/Unit/Content/BlobTest.php +++ b/tests/Unit/Content/BlobTest.php @@ -23,8 +23,6 @@ expect($payload)->toEqual([ 'blob' => base64_encode('raw-bytes'), 'uri' => 'file://avatar.png', - 'name' => 'avatar', - 'title' => 'User Avatar', 'mimeType' => 'image/png', ]); }); @@ -49,8 +47,6 @@ expect($payload)->toMatchArray([ 'blob' => base64_encode('raw-bytes'), 'uri' => 'file://avatar.png', - 'name' => 'avatar', - 'title' => 'User Avatar', 'mimeType' => 'image/png', '_meta' => ['encoding' => 'base64'], ]); diff --git a/tests/Unit/Content/TextTest.php b/tests/Unit/Content/TextTest.php index 95a0f6e1..2934b452 100644 --- a/tests/Unit/Content/TextTest.php +++ b/tests/Unit/Content/TextTest.php @@ -23,8 +23,6 @@ expect($payload)->toEqual([ 'text' => 'Hello world', 'uri' => 'file://readme.txt', - 'name' => 'readme', - 'title' => 'Readme File', 'mimeType' => 'text/plain', ]); }); @@ -49,8 +47,6 @@ expect($payload)->toEqual([ 'text' => 'Hello world', 'uri' => 'file://readme.txt', - 'name' => 'readme', - 'title' => 'Readme File', 'mimeType' => 'text/plain', '_meta' => ['author' => 'John'], ]); diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index c92e6a5a..7ccbd510 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -433,8 +433,6 @@ public function handle(Request $request): Response [ 'text' => 'Resource content with result meta', 'uri' => 'file://resources/with-result-meta.txt', - 'name' => 'resource-with-result-meta-resource', - 'title' => 'Resource With Result Meta Resource', 'mimeType' => 'text/plain', ], ], diff --git a/tests/Unit/Resources/ResourceTest.php b/tests/Unit/Resources/ResourceTest.php index b4b3e439..b56a8648 100644 --- a/tests/Unit/Resources/ResourceTest.php +++ b/tests/Unit/Resources/ResourceTest.php @@ -22,8 +22,6 @@ public function handle(): Response $expected = [ 'text' => 'This is a test resource.', 'uri' => $resource->uri(), - 'name' => $resource->name(), - 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), ]; @@ -61,8 +59,6 @@ public function handle(): Response $expected = [ 'blob' => base64_encode($binaryData), 'uri' => 'file://resources/I_CAN_BE_OVERRIDDEN', - 'name' => $resource->name(), - 'title' => $resource->title(), 'mimeType' => 'image/png', ]; @@ -90,8 +86,6 @@ public function handle(): Response 'text' => 'This is a test resource.', 'uri' => $resource->uri(), - 'name' => $resource->name(), - 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), ]; @@ -118,8 +112,6 @@ public function handle(): Response 'blob' => base64_encode('This is a test resource.'), 'uri' => $resource->uri(), - 'name' => $resource->name(), - 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), ]; From a099fb9adce102100bff023cff1983c5830a2829 Mon Sep 17 00:00:00 2001 From: taylorotwell <463230+taylorotwell@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:24:16 +0000 Subject: [PATCH 05/33] Update CHANGELOG --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26de4dbd..596a6766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Release Notes -## [Unreleased](https://github.com/laravel/mcp/compare/v0.3.3...main) +## [Unreleased](https://github.com/laravel/mcp/compare/v0.3.4...main) + +## [v0.3.4](https://github.com/laravel/mcp/compare/v0.3.3...v0.3.4) - 2025-11-18 + +* Add _meta support by [@pushpak1300](https://github.com/pushpak1300) in https://github.com/laravel/mcp/pull/106 +* Remove non-spec fields from resource content responses by [@pushpak1300](https://github.com/pushpak1300) in https://github.com/laravel/mcp/pull/110 ## [v0.3.3](https://github.com/laravel/mcp/compare/v0.3.2...v0.3.3) - 2025-11-11 From a2b9ec3fc4ad04d0e0e59e6bde83c204c26571ea Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 00:45:11 +0530 Subject: [PATCH 06/33] Merge branch 'main' into add_support_for_resorce_templatees # Conflicts: # tests/Unit/Methods/ReadResourceTest.php --- src/Server/Methods/ReadResource.php | 2 ++ tests/Unit/Methods/ReadResourceTest.php | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index d42f845b..f23af26a 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -6,6 +6,7 @@ use Generator; use Illuminate\Container\Container; +use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -82,6 +83,7 @@ protected function invokeResource(Resource $resource, string $uri): mixed $variables = $resource->uriTemplate()->match($uri) ?? []; $templateRequest = new Request(['uri' => $uri, ...$variables]); + // @phpstan-ignore-next-line return Container::getInstance()->call($resource->handle(...), ['request' => $templateRequest]); } diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index 7ccbd510..96b282d7 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -6,12 +6,12 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\ReadResource; -use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\ResourceTemplate; +use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; -use Tests\Fixtures\ResourceWithResultMetaResource; use Laravel\Mcp\Support\UriTemplate; +use Tests\Fixtures\ResourceWithResultMetaResource; it('returns a valid resource result', function (): void { $resource = $this->makeResource('resource-content'); From 092bef8326ce61ea8196703fc411772871bf7902 Mon Sep 17 00:00:00 2001 From: pushpak1300 <31663512+pushpak1300@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:20:10 +0000 Subject: [PATCH 07/33] Fix code styling --- tests/Unit/Methods/ReadResourceTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index 6461a096..7280c139 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -8,12 +8,12 @@ use Laravel\Mcp\Server\Methods\ReadResource; use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; -use Laravel\Mcp\Server\Transport\JsonRpcRequest; -use Laravel\Mcp\Server\Transport\JsonRpcResponse; -use Laravel\Mcp\Support\UriTemplate; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; +use Laravel\Mcp\Server\Transport\JsonRpcRequest; +use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Laravel\Mcp\Server\Transport\JsonRpcResponse; +use Laravel\Mcp\Support\UriTemplate; use Tests\Fixtures\ResourceWithResultMetaResource; it('returns a valid resource result', function (): void { From 549062681b4b24e322c1882f8e6279154045b8b7 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 13:43:57 +0530 Subject: [PATCH 08/33] Fix test --- src/Server/Methods/ReadResource.php | 1 + src/Server/ResourceTemplate.php | 17 ++++++++++++++--- tests/Unit/Methods/ReadResourceTest.php | 3 --- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index ca0d5742..f23af26a 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -6,6 +6,7 @@ use Generator; use Illuminate\Container\Container; +use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; diff --git a/src/Server/ResourceTemplate.php b/src/Server/ResourceTemplate.php index 434fa09f..78b37116 100644 --- a/src/Server/ResourceTemplate.php +++ b/src/Server/ResourceTemplate.php @@ -15,14 +15,25 @@ public function uri(): string return (string) $this->uriTemplate(); } - public function toArray(): array + /** + * @return array{ + * name: string, + * title: string, + * description: string, + * uriTemplate: string, + * mimeType: string, + * _meta?: array + * } + */ + public function toArray(): array // @phpstan-ignore method.childReturnType { - return [ + // @phpstan-ignore return.type + return $this->mergeMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), 'uriTemplate' => (string) $this->uriTemplate(), 'mimeType' => $this->mimeType(), - ]; + ]); } } diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index 7280c139..96b282d7 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -8,11 +8,8 @@ use Laravel\Mcp\Server\Methods\ReadResource; use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; -use Laravel\Mcp\Server\ServerContext; -use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; -use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Laravel\Mcp\Support\UriTemplate; use Tests\Fixtures\ResourceWithResultMetaResource; From baf00e0c465efdc0491d73cdb957c3f54b5a9457 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 15:02:14 +0530 Subject: [PATCH 09/33] Refactor --- src/Server/Methods/ReadResource.php | 16 +++- src/Support/UriTemplate.php | 20 +++-- tests/Unit/Methods/ReadResourceTest.php | 97 +++++++++++++++++++++++++ tests/Unit/Support/UriTemplateTest.php | 30 ++++++++ 4 files changed, 154 insertions(+), 9 deletions(-) diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index f23af26a..861c1b17 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -54,8 +54,8 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } return is_iterable($response) - ? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource)) - : $this->toJsonRpcResponse($request, $response, $this->serializable($resource)); + ? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource, $uri)) + : $this->toJsonRpcResponse($request, $response, $this->serializable($resource, $uri)); } /** @@ -81,10 +81,18 @@ protected function invokeResource(Resource $resource, string $uri): mixed { if ($resource instanceof ResourceTemplate) { $variables = $resource->uriTemplate()->match($uri) ?? []; - $templateRequest = new Request(['uri' => $uri, ...$variables]); + + $container = Container::getInstance(); + $originalRequest = $container->bound('mcp.request') ? $container->make('mcp.request') : null; + + $templateRequest = new Request( + arguments: ['uri' => $uri, ...$variables, ...($originalRequest?->all() ?? [])], + sessionId: $originalRequest?->sessionId(), + meta: $originalRequest?->meta(), + ); // @phpstan-ignore-next-line - return Container::getInstance()->call($resource->handle(...), ['request' => $templateRequest]); + return $container->call($resource->handle(...), ['request' => $templateRequest]); } // @phpstan-ignore-next-line diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 8ee3bfde..84f6a2c7 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -105,7 +105,11 @@ public function match(string $uri): ?array foreach ($patterns as $patternData) { $pattern .= $patternData['pattern']; - $names[] = ['name' => $patternData['name'], 'exploded' => $part['exploded']]; + $names[] = [ + 'name' => $patternData['name'], + 'exploded' => $part['exploded'], + 'optional' => $patternData['optional'] ?? false, + ]; } } } @@ -123,7 +127,12 @@ public function match(string $uri): ?array foreach ($names as $i => $nameData) { $name = $nameData['name']; $exploded = $nameData['exploded']; - $value = $matches[$i + 1]; + $value = $matches[$i + 1] ?? ''; + + if ($value === '' && $nameData['optional']) { + continue; + } + $cleanName = str_replace('*', '', $name); $result[$cleanName] = $exploded && str_contains($value, ',') ? explode(',', $value) : $value; @@ -318,7 +327,7 @@ private function escapeRegExp(string $str): string /** * @param array{name: string, operator: string, names: list, exploded: bool} $part - * @return list + * @return list */ private function partToRegExp(array $part): array { @@ -330,10 +339,11 @@ private function partToRegExp(array $part): array if ($part['operator'] === '?' || $part['operator'] === '&') { foreach ($part['names'] as $i => $name) { - $prefix = $i === 0 ? '\\'.$part['operator'] : '&'; + $prefix = $i === 0 ? '[?&]' : '&'; $patterns[] = [ - 'pattern' => $prefix.$this->escapeRegExp($name).'=([^&]+)', + 'pattern' => '(?:'.$prefix.$this->escapeRegExp($name).'=([^&]*))?', 'name' => $name, + 'optional' => true, ]; } diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index 96b282d7..c43280aa 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -120,6 +120,40 @@ public function handle(Request $request): Response ], $result); }); +it('returns actual requested URI in response, not the template pattern', function (): void { + $template = new class extends ResourceTemplate + { + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + return Response::text('User data'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $requestedUri = 'file://users/42'; + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => $requestedUri] + ); + + $readResource = new ReadResource; + $result = $readResource->handle($jsonRpcRequest, $context); + $payload = $result->toArray(); + + // The response URI should be the actual requested URI, not the template pattern + expect($payload['result']['contents'][0]['uri'])->toBe($requestedUri) + ->and($payload['result']['contents'][0]['uri'])->not->toBe('file://users/{userId}'); +}); + it('extracts single variable from URI and passes to handler', function (): void { $capturedUserId = null; @@ -238,6 +272,69 @@ public function handle(Request $request): Response ]); }); +it('preserves sessionId and meta from original request for template resources', function (): void { + $capturedSessionId = null; + $capturedMeta = null; + $capturedArguments = null; + + $template = new class($capturedSessionId, $capturedMeta, $capturedArguments) extends ResourceTemplate + { + public function __construct( + private &$sessionIdRef, + private &$metaRef, + private &$argumentsRef + ) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + $this->sessionIdRef = $request->sessionId(); + $this->metaRef = $request->meta(); + $this->argumentsRef = $request->all(); + + return Response::text('test'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $sessionId = 'test-session-123'; + $meta = ['progressToken' => 'abc123']; + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: [ + 'uri' => 'file://users/42', + 'arguments' => ['format' => 'json'], + '_meta' => $meta, + ], + sessionId: $sessionId + ); + + // Bind the mcp.request in container as the Server would do + $container = \Illuminate\Container\Container::getInstance(); + $container->instance('mcp.request', $jsonRpcRequest->toRequest()); + + try { + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($capturedSessionId)->toBe($sessionId) + ->and($capturedMeta)->toBe($meta) + ->and($capturedArguments)->toHaveKey('userId', '42') + ->and($capturedArguments)->toHaveKey('uri', 'file://users/42') + ->and($capturedArguments)->toHaveKey('format', 'json'); + } finally { + $container->forgetInstance('mcp.request'); + } +}); + it('template handler receives variables via request get method', function (): void { $accessMethodWorks = false; diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php index 36b1f68d..ce7c0464 100644 --- a/tests/Unit/Support/UriTemplateTest.php +++ b/tests/Unit/Support/UriTemplateTest.php @@ -191,6 +191,36 @@ expect($template->match('/users/123/extra'))->toBeNull() ->and($template->match('/users'))->toBeNull(); }); + + it('should match URIs with optional query parameters omitted', function (): void { + $template = new UriTemplate('/users{?cursor}'); + // Should match when query param is omitted (RFC 6570 - query params are optional) + expect($template->match('/users'))->toBe([]) + // Should also match when query param is present + ->and($template->match('/users?cursor=abc123'))->toBe(['cursor' => 'abc123']); + }); + + it('should match URIs with some optional query parameters provided', function (): void { + $template = new UriTemplate('/search{?q,page,limit}'); + // All params provided + expect($template->match('/search?q=test&page=1&limit=10'))->toBe([ + 'q' => 'test', + 'page' => '1', + 'limit' => '10', + ]) + // Only some params provided + ->and($template->match('/search?q=test'))->toBe(['q' => 'test']) + // No params provided + ->and($template->match('/search'))->toBe([]); + }); + + it('should match URIs with query parameters when in template order', function (): void { + $template = new UriTemplate('/api{?a,b}'); + // Parameters in template order should match + expect($template->match('/api?a=1&b=2'))->toBe(['a' => '1', 'b' => '2']); + // Note: Different order (e.g. ?b=2&a=1) is not supported as it would require + // query string parsing rather than regex matching + }); }); describe('UriTemplate security and edge cases', function (): void { From e55dda6ef78a88f5d00fccfe03fb5ac89ab1624a Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 16:15:03 +0530 Subject: [PATCH 10/33] Refactor --- src/Server/Methods/ListResourceTemplates.php | 3 +-- src/Server/Methods/ListResources.php | 4 +--- src/Server/ServerContext.php | 21 ++++++++++++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Server/Methods/ListResourceTemplates.php b/src/Server/Methods/ListResourceTemplates.php index 9ab21f8d..da77949c 100644 --- a/src/Server/Methods/ListResourceTemplates.php +++ b/src/Server/Methods/ListResourceTemplates.php @@ -6,7 +6,6 @@ use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Pagination\CursorPaginator; -use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -16,7 +15,7 @@ class ListResourceTemplates implements Method public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse { $paginator = new CursorPaginator( - items: $context->resources()->filter(fn ($resource): bool => $resource instanceof ResourceTemplate), + items: $context->resourceTemplates(), perPage: $context->perPage($request->get('per_page')), cursor: $request->cursor(), ); diff --git a/src/Server/Methods/ListResources.php b/src/Server/Methods/ListResources.php index 76fa9d26..5259ad6f 100644 --- a/src/Server/Methods/ListResources.php +++ b/src/Server/Methods/ListResources.php @@ -6,7 +6,6 @@ use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Pagination\CursorPaginator; -use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -16,8 +15,7 @@ class ListResources implements Method public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse { $paginator = new CursorPaginator( - items: $context->resources() - ->filter(fn ($resource): bool => ! ($resource instanceof ResourceTemplate)), + items: $context->resources(), perPage: $context->perPage($request->get('per_page')), cursor: $request->cursor(), ); diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 7994e032..d1520e00 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -47,11 +47,24 @@ public function tools(): Collection */ public function resources(): Collection { - return collect($this->resources)->map( - fn (Resource|string $resourceClass) => is_string($resourceClass) + return collect($this->resources)->filter(fn ($resource): bool => ! ($resource instanceof ResourceTemplate)) + ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) ? Container::getInstance()->make($resourceClass) - : $resourceClass - )->filter(fn (Resource $resource): bool => $resource->eligibleForRegistration()); + : $resourceClass) + ->filter(fn (Resource $resource): bool => $resource->eligibleForRegistration()); + } + + /** + * @return Collection + */ + public function resourceTemplates(): Collection + { + return collect($this->resources) + ->filter(fn ($resource): bool => ($resource instanceof ResourceTemplate) || (is_string($resource) && is_subclass_of($resource, ResourceTemplate::class))) + ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) + ? Container::getInstance()->make($resourceClass) + : $resourceClass) + ->filter(fn (ResourceTemplate $resource): bool => $resource->eligibleForRegistration()); } /** From e5f152a6d45853c19dc9bb2796600ddb4b2214da Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 16:55:11 +0530 Subject: [PATCH 11/33] Add Test --- src/Server/Methods/ReadResource.php | 74 +++++++------------ src/Server/ResourceTemplate.php | 9 ++- tests/Unit/Methods/ReadResourceTest.php | 12 +-- tests/Unit/Resources/ResourceTemplateTest.php | 4 - 4 files changed, 38 insertions(+), 61 deletions(-) diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index 861c1b17..fcb2eb9d 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -6,7 +6,6 @@ use Generator; use Illuminate\Container\Container; -use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -27,24 +26,17 @@ class ReadResource implements Method 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'); + $uri = $request->get('uri') ?? throw new JsonRpcException( + 'Missing [uri] parameter.', + -32002, + $request->id, + ); - $resource = $this->findResource($context->resources(), $uri); + $resource = $context->resources()->first(fn (Resource $resource): bool => $resource->uri() === $uri) + ?? $context->resourceTemplates()->first(fn (ResourceTemplate $template): bool => ! is_null($template->uriTemplate()->match($uri))); - if (! $resource instanceof Resource) { - throw new JsonRpcException( - "Resource [{$uri}] not found.", - -32002, - $request->id, - ); + if (! $resource) { + throw new JsonRpcException("Resource [{$uri}] not found.", -32002, $request->id); } try { @@ -54,45 +46,31 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } return is_iterable($response) - ? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource, $uri)) - : $this->toJsonRpcResponse($request, $response, $this->serializable($resource, $uri)); - } - - /** - * @param Collection $resources - */ - protected function findResource(Collection $resources, string $uri): ?Resource - { - $resource = $resources->first( - fn (Resource $r): bool => ! ($r instanceof ResourceTemplate) && $r->uri() === $uri - ); - - if ($resource) { - return $resource; - } - - return $resources - ->filter(fn (Resource $r): bool => $r instanceof ResourceTemplate) - // @phpstan-ignore-next-line - ->first(fn (ResourceTemplate $template): bool => $template->uriTemplate()->match($uri) !== null); + ? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource)) + : $this->toJsonRpcResponse($request, $response, $this->serializable($resource)); } protected function invokeResource(Resource $resource, string $uri): mixed { + $container = Container::getInstance(); + if ($resource instanceof ResourceTemplate) { $variables = $resource->uriTemplate()->match($uri) ?? []; - - $container = Container::getInstance(); - $originalRequest = $container->bound('mcp.request') ? $container->make('mcp.request') : null; - - $templateRequest = new Request( - arguments: ['uri' => $uri, ...$variables, ...($originalRequest?->all() ?? [])], - sessionId: $originalRequest?->sessionId(), - meta: $originalRequest?->meta(), - ); + $resource->setUri($uri); + + if ($container->bound('mcp.request')) { + /** @var Request $request */ + $request = $container->make('mcp.request'); + $request->setArguments([ + ...$request->all(), + ...$variables, + ]); + } else { + $request = new Request([...$variables]); + } // @phpstan-ignore-next-line - return $container->call($resource->handle(...), ['request' => $templateRequest]); + return $container->call([$resource, 'handle'], ['request' => $request]); } // @phpstan-ignore-next-line diff --git a/src/Server/ResourceTemplate.php b/src/Server/ResourceTemplate.php index 78b37116..0aff8010 100644 --- a/src/Server/ResourceTemplate.php +++ b/src/Server/ResourceTemplate.php @@ -12,7 +12,14 @@ abstract public function uriTemplate(): UriTemplate; public function uri(): string { - return (string) $this->uriTemplate(); + return $this->uri !== '' + ? $this->uri + : (string) $this->uriTemplate(); + } + + public function setUri(string $uri): void + { + $this->uri = $uri; } /** diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index c43280aa..a945c8d8 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Container\Container; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -16,7 +17,6 @@ it('returns a valid resource result', function (): void { $resource = $this->makeResource('resource-content'); $readResource = new ReadResource; - $context = $this->getServerContext(); $context = $this->getServerContext([ 'resources' => [ $resource, @@ -36,7 +36,6 @@ it('returns a valid resource result for blob resources', function (): void { $resource = $this->makeBinaryResource(__DIR__.'/../../Fixtures/binary.png'); $readResource = new ReadResource; - $context = $this->getServerContext(); $context = $this->getServerContext([ 'resources' => [ $resource, @@ -267,12 +266,11 @@ public function handle(Request $request): Response $readResource->handle($jsonRpcRequest, $context); expect($capturedAll)->toBe([ - 'uri' => $uri, 'userId' => '789', ]); }); -it('preserves sessionId and meta from original request for template resources', function (): void { +it('preserves sessionId and meta from the original request for template resources', function (): void { $capturedSessionId = null; $capturedMeta = null; $capturedArguments = null; @@ -317,8 +315,7 @@ public function handle(Request $request): Response sessionId: $sessionId ); - // Bind the mcp.request in container as the Server would do - $container = \Illuminate\Container\Container::getInstance(); + $container = Container::getInstance(); $container->instance('mcp.request', $jsonRpcRequest->toRequest()); try { @@ -328,7 +325,6 @@ public function handle(Request $request): Response expect($capturedSessionId)->toBe($sessionId) ->and($capturedMeta)->toBe($meta) ->and($capturedArguments)->toHaveKey('userId', '42') - ->and($capturedArguments)->toHaveKey('uri', 'file://users/42') ->and($capturedArguments)->toHaveKey('format', 'json'); } finally { $container->forgetInstance('mcp.request'); @@ -488,7 +484,7 @@ public function handle(Request $request): Response $readResource->handle($jsonRpcRequest, $context); }); -it('returns resource result with result-level meta when using ResponseFactory', function (): void { +it('returns a resource result with result-level meta when using ResponseFactory', function (): void { $request = JsonRpcRequest::from([ 'jsonrpc' => '2.0', 'id' => 1, diff --git a/tests/Unit/Resources/ResourceTemplateTest.php b/tests/Unit/Resources/ResourceTemplateTest.php index d9153585..13358f97 100644 --- a/tests/Unit/Resources/ResourceTemplateTest.php +++ b/tests/Unit/Resources/ResourceTemplateTest.php @@ -198,7 +198,6 @@ public function handle(Request $request): Response $uri = 'file://api/v2/users/alice/posts/hello-world'; - // Extract variables from URI using template $extractedVars = $template->uriTemplate()->match($uri); expect($extractedVars)->toBe([ @@ -207,13 +206,10 @@ public function handle(Request $request): Response 'postId' => 'hello-world', ]); - // Create request with uri and extracted variables $request = new Request(['uri' => $uri, ...$extractedVars]); - // Handle request (this will verify variables inside handle method) $response = $template->handle($request); - // Verify response content $content = $response->content()->toResource($template); expect($content['text'])->toBe('API v2: User alice - Post hello-world') From c9d90283f8b92b8f8308a3ef300fd0d994aadf6d Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 17:32:33 +0530 Subject: [PATCH 12/33] Refactor --- src/Request.php | 10 +++++++++ src/Server/Methods/ReadResource.php | 32 ++++++++++++++--------------- src/Server/ResourceTemplate.php | 12 +++-------- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/Request.php b/src/Request.php index c2fe0ad0..4ff25f13 100644 --- a/src/Request.php +++ b/src/Request.php @@ -61,6 +61,16 @@ public function get(string $key, mixed $default = null): mixed return $this->data($key, $default); } + /** + * @param array $data + */ + public function merge(array $data): static + { + $this->arguments = array_merge($this->arguments, $data); + + return $this; + } + /** * @return array */ diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index fcb2eb9d..2405b2a9 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -24,6 +24,11 @@ class ReadResource implements Method { use InteractsWithResponses; + /** + * @return Generator|JsonRpcResponse + * + * @throws JsonRpcException + */ public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse { $uri = $request->get('uri') ?? throw new JsonRpcException( @@ -46,8 +51,8 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } return is_iterable($response) - ? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource)) - : $this->toJsonRpcResponse($request, $response, $this->serializable($resource)); + ? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource, $uri)) + : $this->toJsonRpcResponse($request, $response, $this->serializable($resource, $uri)); } protected function invokeResource(Resource $resource, string $uri): mixed @@ -56,31 +61,26 @@ protected function invokeResource(Resource $resource, string $uri): mixed if ($resource instanceof ResourceTemplate) { $variables = $resource->uriTemplate()->match($uri) ?? []; - $resource->setUri($uri); - if ($container->bound('mcp.request')) { - /** @var Request $request */ - $request = $container->make('mcp.request'); - $request->setArguments([ - ...$request->all(), - ...$variables, - ]); - } else { - $request = new Request([...$variables]); - } + Container::getInstance()->afterResolving(Request::class, function (Request $request) use ($variables): void { + $request->merge($variables); + }); // @phpstan-ignore-next-line - return $container->call([$resource, 'handle'], ['request' => $request]); + return $container->call([$resource, 'handle']); } // @phpstan-ignore-next-line return Container::getInstance()->call([$resource, 'handle']); } - protected function serializable(Resource $resource): callable + protected function serializable(Resource $resource, string $uri): callable { return fn (ResponseFactory $factory): array => $factory->mergeMeta([ - 'contents' => $factory->responses()->map(fn (Response $response): array => $response->content()->toResource($resource))->all(), + 'contents' => $factory->responses()->map(fn (Response $response): array => [ + ...$response->content()->toResource($resource), + 'uri' => $uri, + ])->all(), ]); } } diff --git a/src/Server/ResourceTemplate.php b/src/Server/ResourceTemplate.php index 0aff8010..dca8988c 100644 --- a/src/Server/ResourceTemplate.php +++ b/src/Server/ResourceTemplate.php @@ -12,14 +12,7 @@ abstract public function uriTemplate(): UriTemplate; public function uri(): string { - return $this->uri !== '' - ? $this->uri - : (string) $this->uriTemplate(); - } - - public function setUri(string $uri): void - { - $this->uri = $uri; + return (string) $this->uriTemplate(); } /** @@ -27,12 +20,13 @@ public function setUri(string $uri): void * name: string, * title: string, * description: string, + * uri?: string, * uriTemplate: string, * mimeType: string, * _meta?: array * } */ - public function toArray(): array // @phpstan-ignore method.childReturnType + public function toArray(): array { // @phpstan-ignore return.type return $this->mergeMeta([ From 2ad7723443f976e67916296feb9d2ddc48c356ab Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 17:58:10 +0530 Subject: [PATCH 13/33] Add Test --- .../MakeResourceTemplateCommandTest.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/Feature/Console/MakeResourceTemplateCommandTest.php diff --git a/tests/Feature/Console/MakeResourceTemplateCommandTest.php b/tests/Feature/Console/MakeResourceTemplateCommandTest.php new file mode 100644 index 00000000..00be440f --- /dev/null +++ b/tests/Feature/Console/MakeResourceTemplateCommandTest.php @@ -0,0 +1,20 @@ +artisan('make:mcp-resource-template', [ + 'name' => 'TestResourceTemplate', + ]); + + $response->assertExitCode(0)->run(); + + $this->assertFileExists(app_path('Mcp/Resources/TestResourceTemplate.php')); +}); + +it('may publish a custom stub', function (): void { + $this->artisan('vendor:publish', [ + '--tag' => 'mcp-stubs', + '--force' => true, + ])->assertExitCode(0)->run(); + + $this->assertFileExists(base_path('stubs/resource-template.stub')); +}); From a9d3a7562668fbe88cfed905981a933f2cf96137 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 18:32:33 +0530 Subject: [PATCH 14/33] Refactor UriTemplate methods --- src/Support/UriTemplate.php | 274 ++++++++---------- stubs/resource-template.stub | 1 - tests/Unit/Support/UriTemplateTest.php | 382 +++++++++++++++---------- 3 files changed, 347 insertions(+), 310 deletions(-) diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 84f6a2c7..4aa02101 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -4,6 +4,8 @@ namespace Laravel\Mcp\Support; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; use InvalidArgumentException; use Stringable; @@ -41,15 +43,11 @@ public static function isTemplate(string $str): bool */ public function getVariableNames(): array { - $names = []; - - foreach ($this->parts as $part) { - if (is_array($part)) { - $names = array_merge($names, $part['names']); - } - } - - return $names; + return collect($this->parts) + ->filter(fn ($part): bool => is_array($part)) + ->flatMap(fn (array $part): array => $part['names']) + ->values() + ->all(); } /** @@ -99,18 +97,18 @@ public function match(string $uri): ?array foreach ($this->parts as $part) { if (is_string($part)) { - $pattern .= $this->escapeRegExp($part); - } else { - $patterns = $this->partToRegExp($part); - - foreach ($patterns as $patternData) { - $pattern .= $patternData['pattern']; - $names[] = [ - 'name' => $patternData['name'], - 'exploded' => $part['exploded'], - 'optional' => $patternData['optional'] ?? false, - ]; - } + $pattern .= preg_quote($part, '#'); + + continue; + } + + foreach ($this->partToRegExp($part) as $patternData) { + $pattern .= $patternData['pattern']; + $names[] = [ + 'name' => $patternData['name'], + 'exploded' => $part['exploded'], + 'optional' => $patternData['optional'] ?? false, + ]; } } @@ -122,23 +120,22 @@ public function match(string $uri): ?array return null; } - $result = []; + return collect($names) + ->mapWithKeys(function (array $nameData, int $i) use ($matches): array { + $value = $matches[$i + 1] ?? ''; - foreach ($names as $i => $nameData) { - $name = $nameData['name']; - $exploded = $nameData['exploded']; - $value = $matches[$i + 1] ?? ''; - - if ($value === '' && $nameData['optional']) { - continue; - } - - $cleanName = str_replace('*', '', $name); + if ($value === '' && $nameData['optional']) { + return []; + } - $result[$cleanName] = $exploded && str_contains($value, ',') ? explode(',', $value) : $value; - } + $cleanName = Str::remove('*', $nameData['name']); + $parsed = $nameData['exploded'] && Str::contains($value, ',') + ? explode(',', $value) + : $value; - return $result; + return [$cleanName => $parsed]; + }) + ->all(); } public function __toString(): string @@ -148,11 +145,11 @@ public function __toString(): string private function validateLength(string $str, int $max, string $context): void { - if (strlen($str) > $max) { - throw new InvalidArgumentException( - sprintf('%s exceeds maximum length of %d characters (got %d)', $context, $max, strlen($str)) - ); - } + throw_if( + Str::length($str) > $max, + InvalidArgumentException::class, + sprintf('%s exceeds maximum length of %d characters (got %d)', $context, $max, Str::length($str)) + ); } /** @@ -164,50 +161,47 @@ private function parse(string $template): array $currentText = ''; $i = 0; $expressionCount = 0; + $length = Str::length($template); - while ($i < strlen($template)) { - if ($template[$i] === '{') { - if ($currentText !== '') { - $parts[] = $currentText; - $currentText = ''; - } + while ($i < $length) { + if ($template[$i] !== '{') { + $currentText .= $template[$i]; + $i++; - $end = strpos($template, '}', $i); + continue; + } - if ($end === false) { - throw new InvalidArgumentException('Unclosed template expression'); - } + if ($currentText !== '') { + $parts[] = $currentText; + $currentText = ''; + } - $expressionCount++; + $end = strpos($template, '}', $i); - if ($expressionCount > self::MAX_TEMPLATE_EXPRESSIONS) { - throw new InvalidArgumentException( - sprintf('Template contains too many expressions (max %d)', self::MAX_TEMPLATE_EXPRESSIONS) - ); - } + throw_if($end === false, InvalidArgumentException::class, 'Unclosed template expression'); - $expr = substr($template, $i + 1, $end - $i - 1); - $operator = $this->getOperator($expr); - $exploded = str_contains($expr, '*'); - $names = $this->getNames($expr); - $name = $names[0] ?? ''; + $expressionCount++; - foreach ($names as $varName) { - $this->validateLength($varName, self::MAX_VARIABLE_LENGTH, 'Variable name'); - } + throw_if( + $expressionCount > self::MAX_TEMPLATE_EXPRESSIONS, + InvalidArgumentException::class, + sprintf('Template contains too many expressions (max %d)', self::MAX_TEMPLATE_EXPRESSIONS) + ); - $parts[] = [ - 'name' => $name, - 'operator' => $operator, - 'names' => $names, - 'exploded' => $exploded, - ]; + $expr = Str::substr($template, $i + 1, $end - $i - 1); + $operator = $this->getOperator($expr); + $names = $this->getNames($expr); - $i = $end + 1; - } else { - $currentText .= $template[$i]; - $i++; - } + collect($names)->each(fn (string $varName) => $this->validateLength($varName, self::MAX_VARIABLE_LENGTH, 'Variable name')); + + $parts[] = [ + 'name' => Arr::first($names, default: ''), + 'operator' => $operator, + 'names' => $names, + 'exploded' => Str::contains($expr, '*'), + ]; + + $i = $end + 1; } if ($currentText !== '') { @@ -219,13 +213,11 @@ private function parse(string $template): array private function getOperator(string $expr): string { - foreach (self::OPERATORS as $op) { - if (str_starts_with($expr, $op)) { - return $op; - } - } - - return ''; + return Arr::first( + self::OPERATORS, + fn (string $op): bool => Str::startsWith($expr, $op), + '' + ); } /** @@ -234,14 +226,14 @@ private function getOperator(string $expr): string private function getNames(string $expr): array { $operator = $this->getOperator($expr); - $withoutOperator = substr($expr, strlen($operator)); - - $names = array_map( - fn ($name): string => str_replace('*', '', trim($name)), - explode(',', $withoutOperator) - ); - return array_values(array_filter($names, fn ($name): bool => $name !== '')); + return Str::of($expr) + ->substr(Str::length($operator)) + ->explode(',') + ->map(fn (string $name): string => Str::remove('*', trim($name))) + ->filter(fn (string $name): bool => filled($name)) + ->values() + ->all(); } private function encodeValue(string $value): string @@ -257,48 +249,28 @@ private function encodeValue(string $value): string */ private function expandPart(array $part, array $variables): string { - if ($part['operator'] === '?' || $part['operator'] === '&') { - $pairs = []; - - foreach ($part['names'] as $name) { - $value = $variables[$name] ?? null; - - if ($value === null) { - continue; - } - - $encoded = is_array($value) - ? implode(',', array_map($this->encodeValue(...), $value)) - : $this->encodeValue((string) $value); - - $pairs[] = $name.'='.$encoded; - } - - if ($pairs === []) { - return ''; - } - - $separator = $part['operator'] === '?' ? '?' : '&'; - - return $separator.implode('&', $pairs); + if (in_array($part['operator'], ['?', '&'], true)) { + $pairs = collect($part['names']) + ->map(fn (string $name): ?string => match (true) { + ! Arr::has($variables, $name) => null, + is_array($variables[$name]) => $name.'='.collect($variables[$name])->map($this->encodeValue(...))->implode(','), + default => $name.'='.$this->encodeValue((string) $variables[$name]), + }) + ->filter() + ->values(); + + return $pairs->isEmpty() + ? '' + : ($part['operator'] === '?' ? '?' : '&').$pairs->implode('&'); } if (count($part['names']) > 1) { - $values = []; - - foreach ($part['names'] as $name) { - $value = $variables[$name] ?? null; - - if ($value !== null) { - $values[] = $value; - } - } + $values = collect($part['names']) + ->map(fn (string $name): mixed => $variables[$name] ?? null) + ->filter(fn (mixed $value): bool => $value !== null) + ->map(fn (mixed $v): string => is_array($v) ? $v[0] : $v); - if ($values === []) { - return ''; - } - - return implode(',', array_map(fn ($v): string => is_array($v) ? $v[0] : $v, $values)); + return $values->isEmpty() ? '' : $values->implode(','); } $value = $variables[$part['name']] ?? null; @@ -307,51 +279,37 @@ private function expandPart(array $part, array $variables): string return ''; } - $values = is_array($value) ? $value : [$value]; - $encoded = array_map($this->encodeValue(...), $values); + $encoded = collect(Arr::wrap($value))->map($this->encodeValue(...)); return match ($part['operator']) { - '' => implode(',', $encoded), - '+' => implode(',', $encoded), - '#' => '#'.implode(',', $encoded), - '.' => '.'.implode('.', $encoded), - '/' => '/'.implode('/', $encoded), - default => implode(',', $encoded), + '', '+' => $encoded->implode(','), + '#' => '#'.$encoded->implode(','), + '.' => '.'.$encoded->implode('.'), + '/' => '/'.$encoded->implode('/'), + default => $encoded->implode(','), }; } - private function escapeRegExp(string $str): string - { - return preg_quote($str, '#'); - } - /** * @param array{name: string, operator: string, names: list, exploded: bool} $part * @return list */ private function partToRegExp(array $part): array { - $patterns = []; - - foreach ($part['names'] as $varName) { - $this->validateLength($varName, self::MAX_VARIABLE_LENGTH, 'Variable name'); - } + collect($part['names'])->each( + fn (string $varName) => $this->validateLength($varName, self::MAX_VARIABLE_LENGTH, 'Variable name') + ); - if ($part['operator'] === '?' || $part['operator'] === '&') { - foreach ($part['names'] as $i => $name) { - $prefix = $i === 0 ? '[?&]' : '&'; - $patterns[] = [ - 'pattern' => '(?:'.$prefix.$this->escapeRegExp($name).'=([^&]*))?', + if (in_array($part['operator'], ['?', '&'], true)) { + return collect($part['names']) + ->map(fn (string $name, int $i): array => [ + 'pattern' => '(?:'.($i === 0 ? '[?&]' : '&').preg_quote($name, '#').'=([^&]*))?', 'name' => $name, 'optional' => true, - ]; - } - - return $patterns; + ]) + ->all(); } - $name = $part['name']; - $pattern = match ($part['operator']) { '' => $part['exploded'] ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)', '+', '#' => '(.+)', @@ -360,8 +318,6 @@ private function partToRegExp(array $part): array default => '([^/]+)', }; - $patterns[] = ['pattern' => $pattern, 'name' => $name]; - - return $patterns; + return [['pattern' => $pattern, 'name' => $part['name']]]; } } diff --git a/stubs/resource-template.stub b/stubs/resource-template.stub index 886bf119..76a4e19c 100644 --- a/stubs/resource-template.stub +++ b/stubs/resource-template.stub @@ -9,7 +9,6 @@ use Laravel\Mcp\Support\UriTemplate; class {{ class }} extends ResourceTemplate { - /** * The resource's description. */ diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php index ce7c0464..128462fc 100644 --- a/tests/Unit/Support/UriTemplateTest.php +++ b/tests/Unit/Support/UriTemplateTest.php @@ -2,15 +2,15 @@ use Laravel\Mcp\Support\UriTemplate; -describe('UriTemplate isTemplate', function (): void { - it('should return true for strings containing template expressions', function (): void { +describe('UriTemplate::isTemplate', function (): void { + it('returns true for strings containing template expressions', function (): void { expect(UriTemplate::isTemplate('{foo}'))->toBeTrue() ->and(UriTemplate::isTemplate('/users/{id}'))->toBeTrue() ->and(UriTemplate::isTemplate('http://example.com/{path}/{file}'))->toBeTrue() ->and(UriTemplate::isTemplate('/search{?q,limit}'))->toBeTrue(); }); - it('should return false for strings without template expressions', function (): void { + it('returns false for strings without template expressions', function (): void { expect(UriTemplate::isTemplate(''))->toBeFalse() ->and(UriTemplate::isTemplate('plain string'))->toBeFalse() ->and(UriTemplate::isTemplate('http://example.com/foo/bar'))->toBeFalse() @@ -19,295 +19,377 @@ }); }); -describe('UriTemplate simple string expansion', function (): void { - it('should expand simple string variables', function (): void { +describe('UriTemplate simple expansion', function (): void { + it('expands simple string variables', function (): void { $template = new UriTemplate('http://example.com/users/{username}'); + expect($template->expand(['username' => 'fred']))->toBe('http://example.com/users/fred') ->and($template->getVariableNames())->toBe(['username']); }); - it('should handle multiple variables', function (): void { + it('handles multiple variables', function (): void { $template = new UriTemplate('{x,y}'); + expect($template->expand(['x' => '1024', 'y' => '768']))->toBe('1024,768') ->and($template->getVariableNames())->toBe(['x', 'y']); }); - it('should encode reserved characters', function (): void { + it('encodes reserved characters', function (): void { $template = new UriTemplate('{var}'); + expect($template->expand(['var' => 'value with spaces']))->toBe('value%20with%20spaces'); }); }); -describe('UriTemplate reserved expansion', function (): void { - it('should not encode reserved characters with + operator', function (): void { +describe('UriTemplate reserved expansion (+)', function (): void { + it('does not encode reserved characters', function (): void { $template = new UriTemplate('{+path}/here'); + expect($template->expand(['path' => '/foo/bar']))->toBe('%2Ffoo%2Fbar/here') ->and($template->getVariableNames())->toBe(['path']); }); + + it('handles arrays', function (): void { + $template = new UriTemplate('{+list}'); + + expect($template->expand(['list' => ['red', 'green', 'blue']]))->toBe('red,green,blue'); + }); }); -describe('UriTemplate fragment expansion', function (): void { - it('should add # prefix and not encode reserved chars', function (): void { +describe('UriTemplate fragment expansion (#)', function (): void { + it('adds # prefix', function (): void { $template = new UriTemplate('X{#var}'); + expect($template->expand(['var' => '/test']))->toBe('X#%2Ftest') ->and($template->getVariableNames())->toBe(['var']); }); + + it('handles arrays', function (): void { + $template = new UriTemplate('X{#list}'); + + expect($template->expand(['list' => ['red', 'green', 'blue']]))->toBe('X#red,green,blue'); + }); }); -describe('UriTemplate label expansion', function (): void { - it('should add . prefix', function (): void { +describe('UriTemplate label expansion (.)', function (): void { + it('adds . prefix', function (): void { $template = new UriTemplate('X{.var}'); + expect($template->expand(['var' => 'test']))->toBe('X.test') ->and($template->getVariableNames())->toBe(['var']); }); + + it('handles arrays', function (): void { + $template = new UriTemplate('X{.list}'); + + expect($template->expand(['list' => ['red', 'green', 'blue']]))->toBe('X.red.green.blue'); + }); }); -describe('UriTemplate path expansion', function (): void { - it('should add / prefix', function (): void { +describe('UriTemplate path expansion (/)', function (): void { + it('adds / prefix', function (): void { $template = new UriTemplate('X{/var}'); + expect($template->expand(['var' => 'test']))->toBe('X/test') ->and($template->getVariableNames())->toBe(['var']); }); + + it('handles arrays', function (): void { + $template = new UriTemplate('X{/list}'); + + expect($template->expand(['list' => ['red', 'green', 'blue']]))->toBe('X/red/green/blue'); + }); }); -describe('UriTemplate query expansion', function (): void { - it('should add ? prefix and name=value format', function (): void { +describe('UriTemplate query expansion (?)', function (): void { + it('adds ? prefix and name=value format', function (): void { $template = new UriTemplate('X{?var}'); + expect($template->expand(['var' => 'test']))->toBe('X?var=test') ->and($template->getVariableNames())->toBe(['var']); }); + + it('handles multiple parameters', function (): void { + $template = new UriTemplate('/search{?q,page,limit}'); + + expect($template->expand(['q' => 'test', 'page' => '1', 'limit' => '10'])) + ->toBe('/search?q=test&page=1&limit=10') + ->and($template->getVariableNames())->toBe(['q', 'page', 'limit']); + }); + + it('handles arrays', function (): void { + $template = new UriTemplate('/search{?tags*}'); + + expect($template->expand(['tags' => ['nodejs', 'typescript', 'testing']])) + ->toBe('/search?tags=nodejs,typescript,testing') + ->and($template->getVariableNames())->toBe(['tags']); + }); }); -describe('UriTemplate form continuation expansion', function (): void { - it('should add & prefix and name=value format', function (): void { +describe('UriTemplate form continuation expansion (&)', function (): void { + it('adds & prefix and name=value format', function (): void { $template = new UriTemplate('X{&var}'); + expect($template->expand(['var' => 'test']))->toBe('X&var=test') ->and($template->getVariableNames())->toBe(['var']); }); }); -describe('UriTemplate matching', function (): void { - it('should match simple strings and extract variables', function (): void { +describe('UriTemplate::match', function (): void { + it('extracts variables from simple strings', function (): void { $template = new UriTemplate('http://example.com/users/{username}'); - $match = $template->match('http://example.com/users/fred'); - expect($match)->toBe(['username' => 'fred']); + + expect($template->match('http://example.com/users/fred'))->toBe(['username' => 'fred']); }); - it('should match multiple variables', function (): void { + it('extracts multiple variables', function (): void { $template = new UriTemplate('/users/{username}/posts/{postId}'); - $match = $template->match('/users/fred/posts/123'); - expect($match)->toBe(['username' => 'fred', 'postId' => '123']); - }); - it('should return null for non-matching URIs', function (): void { - $template = new UriTemplate('/users/{username}'); - $match = $template->match('/posts/123'); - expect($match)->toBeNull(); + expect($template->match('/users/fred/posts/123'))->toBe(['username' => 'fred', 'postId' => '123']); }); - it('should handle exploded arrays', function (): void { - $template = new UriTemplate('{/list*}'); - $match = $template->match('/red,green,blue'); - expect($match)->toBe(['list' => ['red', 'green', 'blue']]); - }); -}); + it('returns null for non-matching URIs', function (): void { + $template = new UriTemplate('/users/{username}'); -describe('UriTemplate edge cases', function (): void { - it('should handle empty variables', function (): void { - $template = new UriTemplate('{empty}'); - expect($template->expand([]))->toBe('') - ->and($template->expand(['empty' => '']))->toBe(''); + expect($template->match('/posts/123'))->toBeNull(); }); - it('should handle undefined variables', function (): void { - $template = new UriTemplate('{a}{b}{c}'); - expect($template->expand(['b' => '2']))->toBe('2'); - }); + it('handles exploded arrays', function (): void { + $template = new UriTemplate('{/list*}'); - it('should handle special characters in variable names', function (): void { - $template = new UriTemplate('{$var_name}'); - expect($template->expand(['$var_name' => 'value']))->toBe('value'); + expect($template->match('/red,green,blue'))->toBe(['list' => ['red', 'green', 'blue']]); }); -}); -describe('UriTemplate complex patterns', function (): void { - it('should handle nested path segments', function (): void { + it('matches nested path segments', function (): void { $template = new UriTemplate('/api/{version}/{resource}/{id}'); - expect($template->expand([ - 'version' => 'v1', - 'resource' => 'users', - 'id' => '123', - ]))->toBe('/api/v1/users/123') - ->and($template->getVariableNames())->toBe(['version', 'resource', 'id']); - }); - it('should handle query parameters with arrays', function (): void { - $template = new UriTemplate('/search{?tags*}'); - expect($template->expand([ - 'tags' => ['nodejs', 'typescript', 'testing'], - ]))->toBe('/search?tags=nodejs,typescript,testing') - ->and($template->getVariableNames())->toBe(['tags']); - }); - - it('should handle multiple query parameters', function (): void { - $template = new UriTemplate('/search{?q,page,limit}'); - expect($template->expand([ - 'q' => 'test', - 'page' => '1', - 'limit' => '10', - ]))->toBe('/search?q=test&page=1&limit=10') - ->and($template->getVariableNames())->toBe(['q', 'page', 'limit']); - }); -}); - -describe('UriTemplate matching complex patterns', function (): void { - it('should match nested path segments', function (): void { - $template = new UriTemplate('/api/{version}/{resource}/{id}'); - $match = $template->match('/api/v1/users/123'); - expect($match)->toBe([ + expect($template->match('/api/v1/users/123'))->toBe([ 'version' => 'v1', 'resource' => 'users', 'id' => '123', - ]) - ->and($template->getVariableNames())->toBe(['version', 'resource', 'id']); + ]); }); - it('should match query parameters', function (): void { + it('matches query parameters', function (): void { $template = new UriTemplate('/search{?q}'); - $match = $template->match('/search?q=test'); - expect($match)->toBe(['q' => 'test']) - ->and($template->getVariableNames())->toBe(['q']); + + expect($template->match('/search?q=test'))->toBe(['q' => 'test']); }); - it('should match multiple query parameters', function (): void { + it('matches multiple query parameters', function (): void { $template = new UriTemplate('/search{?q,page}'); - $match = $template->match('/search?q=test&page=1'); - expect($match)->toBe(['q' => 'test', 'page' => '1']) - ->and($template->getVariableNames())->toBe(['q', 'page']); + + expect($template->match('/search?q=test&page=1'))->toBe(['q' => 'test', 'page' => '1']); }); - it('should handle partial matches correctly', function (): void { + it('rejects partial matches', function (): void { $template = new UriTemplate('/users/{id}'); + expect($template->match('/users/123/extra'))->toBeNull() ->and($template->match('/users'))->toBeNull(); }); - it('should match URIs with optional query parameters omitted', function (): void { + it('matches label expansion patterns', function (): void { + $template = new UriTemplate('X{.var}'); + + expect($template->match('X.test'))->toBe(['var' => 'test']) + ->and($template->match('Xtest'))->toBeNull(); + }); + + it('matches fragment expansion patterns', function (): void { + $template = new UriTemplate('X{#var}'); + + expect($template->match('X#test/value'))->toBe(['var' => '#test/value']); + }); + + it('matches reserved expansion patterns', function (): void { + $template = new UriTemplate('{+path}'); + + expect($template->match('/foo/bar'))->toBe(['path' => '/foo/bar']); + }); + + it('matches URIs with optional query parameters omitted', function (): void { $template = new UriTemplate('/users{?cursor}'); - // Should match when query param is omitted (RFC 6570 - query params are optional) + expect($template->match('/users'))->toBe([]) - // Should also match when query param is present ->and($template->match('/users?cursor=abc123'))->toBe(['cursor' => 'abc123']); }); - it('should match URIs with some optional query parameters provided', function (): void { + it('matches URIs with some optional query parameters provided', function (): void { $template = new UriTemplate('/search{?q,page,limit}'); - // All params provided + expect($template->match('/search?q=test&page=1&limit=10'))->toBe([ 'q' => 'test', 'page' => '1', 'limit' => '10', ]) - // Only some params provided ->and($template->match('/search?q=test'))->toBe(['q' => 'test']) - // No params provided ->and($template->match('/search'))->toBe([]); }); - it('should match URIs with query parameters when in template order', function (): void { + it('matches query parameters in template order', function (): void { $template = new UriTemplate('/api{?a,b}'); - // Parameters in template order should match + expect($template->match('/api?a=1&b=2'))->toBe(['a' => '1', 'b' => '2']); - // Note: Different order (e.g. ?b=2&a=1) is not supported as it would require - // query string parsing rather than regex matching }); }); -describe('UriTemplate security and edge cases', function (): void { - it('should handle extremely long input strings', function (): void { +describe('UriTemplate edge cases', function (): void { + it('handles empty variables', function (): void { + $template = new UriTemplate('{empty}'); + + expect($template->expand([]))->toBe('') + ->and($template->expand(['empty' => '']))->toBe(''); + }); + + it('handles undefined variables', function (): void { + $template = new UriTemplate('{a}{b}{c}'); + + expect($template->expand(['b' => '2']))->toBe('2'); + }); + + it('handles special characters in variable names', function (): void { + $template = new UriTemplate('{$var_name}'); + + expect($template->expand(['$var_name' => 'value']))->toBe('value'); + }); + + it('returns empty string for multiple null variables', function (): void { + $template = new UriTemplate('{x,y,z}'); + + expect($template->expand([]))->toBe('') + ->and($template->expand(['other' => 'value']))->toBe(''); + }); + + it('handles multiple variables with array values', function (): void { + $template = new UriTemplate('{x,y}'); + + expect($template->expand(['x' => ['a', 'b'], 'y' => 'c']))->toBe('a,c') + ->and($template->expand(['x' => ['first'], 'y' => ['second']]))->toBe('first,second'); + }); + + it('handles nested path segments', function (): void { + $template = new UriTemplate('/api/{version}/{resource}/{id}'); + + expect($template->expand(['version' => 'v1', 'resource' => 'users', 'id' => '123'])) + ->toBe('/api/v1/users/123') + ->and($template->getVariableNames())->toBe(['version', 'resource', 'id']); + }); + + it('handles repeated operators', function (): void { + $template = new UriTemplate('{?a}{?b}{?c}'); + + expect($template->expand(['a' => '1', 'b' => '2', 'c' => '3']))->toBe('?a=1&b=2&c=3') + ->and($template->getVariableNames())->toBe(['a', 'b', 'c']); + }); + + it('handles overlapping variable names', function (): void { + $template = new UriTemplate('{var}{vara}'); + + expect($template->expand(['var' => '1', 'vara' => '2']))->toBe('12') + ->and($template->getVariableNames())->toBe(['var', 'vara']); + }); + + it('handles empty segments', function (): void { + $template = new UriTemplate('///{a}////{b}////'); + + expect($template->expand(['a' => '1', 'b' => '2']))->toBe('///1////2////') + ->and($template->match('///1////2////'))->toBe(['a' => '1', 'b' => '2']) + ->and($template->getVariableNames())->toBe(['a', 'b']); + }); +}); + +describe('UriTemplate security', function (): void { + it('handles extremely long input strings', function (): void { $longString = str_repeat('x', 100000); $template = new UriTemplate('/api/{param}'); + expect($template->expand(['param' => $longString]))->toBe('/api/'.$longString) ->and($template->match('/api/'.$longString))->toBe(['param' => $longString]); }); - it('should handle deeply nested template expressions', function (): void { + it('throws when template exceeds maximum length', function (): void { + $longTemplate = str_repeat('x', 1000001); + + expect(fn (): UriTemplate => new UriTemplate($longTemplate)) + ->toThrow(InvalidArgumentException::class, 'Template exceeds maximum length'); + }); + + it('throws when URI exceeds maximum length', function (): void { + $template = new UriTemplate('/api/{param}'); + $longUri = '/api/'.str_repeat('x', 1000001); + + expect(fn (): ?array => $template->match($longUri)) + ->toThrow(InvalidArgumentException::class, 'URI exceeds maximum length'); + }); + + it('throws when template has too many expressions', function (): void { + $tooManyExpressions = str_repeat('{a}', 10001); + + expect(fn (): UriTemplate => new UriTemplate($tooManyExpressions)) + ->toThrow(InvalidArgumentException::class, 'Template contains too many expressions'); + }); + + it('handles deeply nested template expressions', function (): void { $template = new UriTemplate(str_repeat('{a}{b}{c}{d}{e}{f}{g}{h}{i}{j}', 1000)); + expect(fn (): string => $template->expand([ - 'a' => '1', - 'b' => '2', - 'c' => '3', - 'd' => '4', - 'e' => '5', - 'f' => '6', - 'g' => '7', - 'h' => '8', - 'i' => '9', - 'j' => '0', + 'a' => '1', 'b' => '2', 'c' => '3', 'd' => '4', 'e' => '5', + 'f' => '6', 'g' => '7', 'h' => '8', 'i' => '9', 'j' => '0', ]))->not->toThrow(Exception::class); }); - it('should handle malformed template expressions', function (): void { - expect(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{unclosed'))->toThrow(InvalidArgumentException::class) - ->and(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{}'))->not->toThrow(Exception::class) - ->and(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{,}'))->not->toThrow(Exception::class) - ->and(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('{a}{'))->toThrow(InvalidArgumentException::class); + it('throws for unclosed template expressions', function (): void { + expect(fn (): UriTemplate => new UriTemplate('{unclosed')) + ->toThrow(InvalidArgumentException::class); }); - it('should handle pathological regex patterns', function (): void { + it('handles empty template expressions', function (): void { + expect(fn (): UriTemplate => new UriTemplate('{}'))->not->toThrow(Exception::class) + ->and(fn (): UriTemplate => new UriTemplate('{,}'))->not->toThrow(Exception::class); + }); + + it('handles pathological regex patterns', function (): void { $template = new UriTemplate('/api/{param}'); $input = '/api/'.str_repeat('a', 100000); + expect(fn (): ?array => $template->match($input))->not->toThrow(Exception::class); }); - it('should handle invalid UTF-8 sequences', function (): void { + it('handles invalid UTF-8 sequences', function (): void { $template = new UriTemplate('/api/{param}'); $invalidUtf8 = '���'; + expect(fn (): string => $template->expand(['param' => $invalidUtf8]))->not->toThrow(Exception::class) ->and(fn (): ?array => $template->match('/api/'.$invalidUtf8))->not->toThrow(Exception::class); }); - it('should handle template/URI length mismatches', function (): void { + it('handles template/URI length mismatches', function (): void { $template = new UriTemplate('/api/{param}'); + expect($template->match('/api/'))->toBeNull() ->and($template->match('/api'))->toBeNull() ->and($template->match('/api/value/extra'))->toBeNull(); }); - it('should handle repeated operators', function (): void { - $template = new UriTemplate('{?a}{?b}{?c}'); - expect($template->expand(['a' => '1', 'b' => '2', 'c' => '3']))->toBe('?a=1&b=2&c=3') - ->and($template->getVariableNames())->toBe(['a', 'b', 'c']); - }); - - it('should handle overlapping variable names', function (): void { - $template = new UriTemplate('{var}{vara}'); - expect($template->expand(['var' => '1', 'vara' => '2']))->toBe('12') - ->and($template->getVariableNames())->toBe(['var', 'vara']); - }); - - it('should handle empty segments', function (): void { - $template = new UriTemplate('///{a}////{b}////'); - expect($template->expand(['a' => '1', 'b' => '2']))->toBe('///1////2////') - ->and($template->match('///1////2////'))->toBe(['a' => '1', 'b' => '2']) - ->and($template->getVariableNames())->toBe(['a', 'b']); - }); - - it('should handle maximum template expression limit', function (): void { + it('handles maximum template expression limit', function (): void { $expressions = str_repeat('{param}', 10000); - expect(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate($expressions))->not->toThrow(Exception::class); + + expect(fn (): UriTemplate => new UriTemplate($expressions))->not->toThrow(Exception::class); }); - it('should handle maximum variable name length', function (): void { + it('handles maximum variable name length', function (): void { $longName = str_repeat('a', 10000); $template = new UriTemplate('{'.$longName.'}'); + expect(fn (): string => $template->expand([$longName => 'value']))->not->toThrow(Exception::class); }); }); -describe('UriTemplate stringable', function (): void { - it('should cast to string', function (): void { +describe('UriTemplate::__toString', function (): void { + it('casts to string', function (): void { $template = new UriTemplate('/users/{id}'); + expect((string) $template)->toBe('/users/{id}'); }); }); From 56c1a40be2fe7d4d2e9096b572c40dc1fd00d336 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 18:52:45 +0530 Subject: [PATCH 15/33] Refactor --- src/Server/Methods/ReadResource.php | 13 ++++-- src/Support/UriTemplate.php | 62 +++++++++++++++---------- tests/Unit/Methods/ReadResourceTest.php | 57 +++++++++++++++++++++++ 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index 2405b2a9..db8e0a38 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -62,12 +62,15 @@ protected function invokeResource(Resource $resource, string $uri): mixed if ($resource instanceof ResourceTemplate) { $variables = $resource->uriTemplate()->match($uri) ?? []; - Container::getInstance()->afterResolving(Request::class, function (Request $request) use ($variables): void { - $request->merge($variables); - }); + $request = $container->make(Request::class); + $container->instance(Request::class, $request->merge($variables)); - // @phpstan-ignore-next-line - return $container->call([$resource, 'handle']); + try { + // @phpstan-ignore-next-line + return $container->call([$resource, 'handle']); + } finally { + $container->forgetInstance(Request::class); + } } // @phpstan-ignore-next-line diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 4aa02101..91bce214 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -43,11 +43,15 @@ public static function isTemplate(string $str): bool */ public function getVariableNames(): array { - return collect($this->parts) - ->filter(fn ($part): bool => is_array($part)) - ->flatMap(fn (array $part): array => $part['names']) - ->values() - ->all(); + $names = []; + + foreach ($this->parts as $part) { + if (is_array($part)) { + $names = array_merge($names, $part['names']); + } + } + + return $names; } /** @@ -227,13 +231,15 @@ private function getNames(string $expr): array { $operator = $this->getOperator($expr); - return Str::of($expr) - ->substr(Str::length($operator)) - ->explode(',') - ->map(fn (string $name): string => Str::remove('*', trim($name))) - ->filter(fn (string $name): bool => filled($name)) - ->values() - ->all(); + return array_values( + Str::of($expr) + ->substr(Str::length($operator)) + ->explode(',') + ->map(fn (string $name): string => Str::remove('*', trim($name))) + ->filter(fn (string $name): bool => filled($name)) + ->values() + ->all() + ); } private function encodeValue(string $value): string @@ -257,20 +263,24 @@ private function expandPart(array $part, array $variables): string default => $name.'='.$this->encodeValue((string) $variables[$name]), }) ->filter() - ->values(); + ->values() + ->all(); + + if (count($pairs) === 0) { + return ''; + } - return $pairs->isEmpty() - ? '' - : ($part['operator'] === '?' ? '?' : '&').$pairs->implode('&'); + return ($part['operator'] === '?' ? '?' : '&').implode('&', $pairs); } if (count($part['names']) > 1) { $values = collect($part['names']) ->map(fn (string $name): mixed => $variables[$name] ?? null) ->filter(fn (mixed $value): bool => $value !== null) - ->map(fn (mixed $v): string => is_array($v) ? $v[0] : $v); + ->map(fn (mixed $v): string => is_array($v) ? $v[0] : (string) $v) + ->all(); - return $values->isEmpty() ? '' : $values->implode(','); + return count($values) === 0 ? '' : implode(',', $values); } $value = $variables[$part['name']] ?? null; @@ -301,13 +311,15 @@ private function partToRegExp(array $part): array ); if (in_array($part['operator'], ['?', '&'], true)) { - return collect($part['names']) - ->map(fn (string $name, int $i): array => [ - 'pattern' => '(?:'.($i === 0 ? '[?&]' : '&').preg_quote($name, '#').'=([^&]*))?', - 'name' => $name, - 'optional' => true, - ]) - ->all(); + return array_values( + collect($part['names']) + ->map(fn (string $name, int $i): array => [ + 'pattern' => '(?:'.($i === 0 ? '[?&]' : '&').preg_quote($name, '#').'=([^&]*))?', + 'name' => $name, + 'optional' => true, + ]) + ->all() + ); } $pattern = match ($part['operator']) { diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index a945c8d8..b818e50c 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -532,3 +532,60 @@ public function handle(Request $request): Response ], ]); }); + +it('does not leak variables between consecutive template resource requests', function (): void { + $firstRequestVars = null; + $secondRequestVars = null; + + $template = new class($firstRequestVars, $secondRequestVars) extends ResourceTemplate + { + public function __construct(private &$firstRef, private &$secondRef) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}/posts/{postId}'); + } + + public function handle(Request $request): Response + { + if ($this->firstRef === null) { + $this->firstRef = $request->all(); + } else { + $this->secondRef = $request->all(); + } + + return Response::text('test'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $readResource = new ReadResource; + + $firstJsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://users/100/posts/42'] + ); + $readResource->handle($firstJsonRpcRequest, $context); + + $secondJsonRpcRequest = new JsonRpcRequest( + id: 2, + method: 'resources/read', + params: ['uri' => 'file://users/200/posts/99'] + ); + $readResource->handle($secondJsonRpcRequest, $context); + + expect($firstRequestVars)->toBe([ + 'userId' => '100', + 'postId' => '42', + ]) + ->and($secondRequestVars)->toBe([ + 'userId' => '200', + 'postId' => '99', + ]) + ->and($secondRequestVars)->not->toHaveKey('userId', '100') + ->and($secondRequestVars)->not->toHaveKey('postId', '42'); +}); From 34f0e57cca59aa2b8918d0601bd8a7466b33cb95 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 21:08:12 +0530 Subject: [PATCH 16/33] Refactor --- src/Support/UriTemplate.php | 294 +++------------ tests/Unit/Resources/ResourceTemplateTest.php | 5 +- tests/Unit/Support/UriTemplateTest.php | 345 +++--------------- 3 files changed, 103 insertions(+), 541 deletions(-) diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 91bce214..0966624c 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -4,7 +4,6 @@ namespace Laravel\Mcp\Support; -use Illuminate\Support\Arr; use Illuminate\Support\Str; use InvalidArgumentException; use Stringable; @@ -19,127 +18,48 @@ class UriTemplate implements Stringable private const MAX_REGEX_LENGTH = 1000000; - private const OPERATORS = ['+', '#', '.', '/', '?', '&']; + private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*{[^{}]+}.*/'; private string $template; - /** @var list, exploded: bool}> */ - private array $parts; + /** @var list */ + private array $variableNames = []; + + private ?string $compiledRegex = null; public function __construct(string $template) { $this->validateLength($template, self::MAX_TEMPLATE_LENGTH, 'Template'); - $this->template = $template; - $this->parts = $this->parse($template); - } - - public static function isTemplate(string $str): bool - { - return (bool) preg_match('/\{[^}\s]+\}/', $str); - } - - /** - * @return list - */ - public function getVariableNames(): array - { - $names = []; - - foreach ($this->parts as $part) { - if (is_array($part)) { - $names = array_merge($names, $part['names']); - } - } - - return $names; - } - - /** - * @param array> $variables - */ - public function expand(array $variables): string - { - $result = ''; - $hasQueryParam = false; - foreach ($this->parts as $part) { - if (is_string($part)) { - $result .= $part; - - continue; - } - - $expanded = $this->expandPart($part, $variables); - - if ($expanded === '') { - continue; - } - - if (($part['operator'] === '?' || $part['operator'] === '&') && $hasQueryParam) { - $result .= str_replace('?', '&', $expanded); - } else { - $result .= $expanded; - } - - if ($part['operator'] === '?' || $part['operator'] === '&') { - $hasQueryParam = true; - } + if (! preg_match(self::URI_TEMPLATE_PATTERN, $template)) { + throw new InvalidArgumentException('Invalid URI template: must be a valid URI template with at least one placeholder.'); } - return $result; + $this->template = $template; + $this->variableNames = $this->extractVariableNames($template); } /** - * @return array>|null + * @return array|null */ public function match(string $uri): ?array { $this->validateLength($uri, self::MAX_TEMPLATE_LENGTH, 'URI'); - $pattern = '^'; - $names = []; - - foreach ($this->parts as $part) { - if (is_string($part)) { - $pattern .= preg_quote($part, '#'); - - continue; - } - - foreach ($this->partToRegExp($part) as $patternData) { - $pattern .= $patternData['pattern']; - $names[] = [ - 'name' => $patternData['name'], - 'exploded' => $part['exploded'], - 'optional' => $patternData['optional'] ?? false, - ]; - } + if (is_null($this->compiledRegex)) { + $this->compiledRegex = $this->compileRegex(); } - $pattern .= '$'; - - $this->validateLength($pattern, self::MAX_REGEX_LENGTH, 'Generated regex pattern'); - - if (! preg_match('#'.$pattern.'#', $uri, $matches)) { + if (! preg_match($this->compiledRegex, $uri, $matches)) { return null; } - return collect($names) - ->mapWithKeys(function (array $nameData, int $i) use ($matches): array { - $value = $matches[$i + 1] ?? ''; - - if ($value === '' && $nameData['optional']) { - return []; - } - - $cleanName = Str::remove('*', $nameData['name']); - $parsed = $nameData['exploded'] && Str::contains($value, ',') - ? explode(',', $value) - : $value; + $result = []; + foreach ($this->variableNames as $i => $name) { + $result[$name] = $matches[$i + 1] ?? ''; + } - return [$cleanName => $parsed]; - }) - ->all(); + return $result; } public function __toString(): string @@ -152,38 +72,23 @@ private function validateLength(string $str, int $max, string $context): void throw_if( Str::length($str) > $max, InvalidArgumentException::class, - sprintf('%s exceeds maximum length of %d characters (got %d)', $context, $max, Str::length($str)) + sprintf('%s exceeds the maximum length of %d characters (got %d)', $context, $max, Str::length($str)) ); } /** - * @return list, exploded: bool}> + * @return list */ - private function parse(string $template): array + private function extractVariableNames(string $template): array { - $parts = []; - $currentText = ''; - $i = 0; $expressionCount = 0; - $length = Str::length($template); - - while ($i < $length) { - if ($template[$i] !== '{') { - $currentText .= $template[$i]; - $i++; - - continue; - } - - if ($currentText !== '') { - $parts[] = $currentText; - $currentText = ''; - } - - $end = strpos($template, '}', $i); + $names = []; - throw_if($end === false, InvalidArgumentException::class, 'Unclosed template expression'); + if (! preg_match_all('/\{(\w+)}/', $template, $matches)) { + return []; + } + foreach ($matches[1] as $name) { $expressionCount++; throw_if( @@ -192,144 +97,41 @@ private function parse(string $template): array sprintf('Template contains too many expressions (max %d)', self::MAX_TEMPLATE_EXPRESSIONS) ); - $expr = Str::substr($template, $i + 1, $end - $i - 1); - $operator = $this->getOperator($expr); - $names = $this->getNames($expr); - - collect($names)->each(fn (string $varName) => $this->validateLength($varName, self::MAX_VARIABLE_LENGTH, 'Variable name')); - - $parts[] = [ - 'name' => Arr::first($names, default: ''), - 'operator' => $operator, - 'names' => $names, - 'exploded' => Str::contains($expr, '*'), - ]; - - $i = $end + 1; + $this->validateLength($name, self::MAX_VARIABLE_LENGTH, 'Variable name'); + $names[] = $name; } - if ($currentText !== '') { - $parts[] = $currentText; - } - - return $parts; - } - - private function getOperator(string $expr): string - { - return Arr::first( - self::OPERATORS, - fn (string $op): bool => Str::startsWith($expr, $op), - '' - ); - } - - /** - * @return list - */ - private function getNames(string $expr): array - { - $operator = $this->getOperator($expr); - - return array_values( - Str::of($expr) - ->substr(Str::length($operator)) - ->explode(',') - ->map(fn (string $name): string => Str::remove('*', trim($name))) - ->filter(fn (string $name): bool => filled($name)) - ->values() - ->all() - ); + return $names; } - private function encodeValue(string $value): string + private function compileRegex(): string { - $this->validateLength($value, self::MAX_VARIABLE_LENGTH, 'Variable value'); - - return rawurlencode($value); - } + $regexParts = []; - /** - * @param array{name: string, operator: string, names: list, exploded: bool} $part - * @param array> $variables - */ - private function expandPart(array $part, array $variables): string - { - if (in_array($part['operator'], ['?', '&'], true)) { - $pairs = collect($part['names']) - ->map(fn (string $name): ?string => match (true) { - ! Arr::has($variables, $name) => null, - is_array($variables[$name]) => $name.'='.collect($variables[$name])->map($this->encodeValue(...))->implode(','), - default => $name.'='.$this->encodeValue((string) $variables[$name]), - }) - ->filter() - ->values() - ->all(); - - if (count($pairs) === 0) { - return ''; - } - - return ($part['operator'] === '?' ? '?' : '&').implode('&', $pairs); - } + $segments = preg_split('/(\{\w+})/', $this->template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - if (count($part['names']) > 1) { - $values = collect($part['names']) - ->map(fn (string $name): mixed => $variables[$name] ?? null) - ->filter(fn (mixed $value): bool => $value !== null) - ->map(fn (mixed $v): string => is_array($v) ? $v[0] : (string) $v) - ->all(); + throw_unless( + $segments, + InvalidArgumentException::class, + 'Failed to compile URI template regex: preg_split error' + ); - return count($values) === 0 ? '' : implode(',', $values); - } + foreach ($segments as $segment) { + $isVariable = preg_match('/^\{(\w+)}$/', $segment); - $value = $variables[$part['name']] ?? null; + throw_if( + $isVariable === false, + InvalidArgumentException::class, + 'Failed to validate template segment: preg_match error' + ); - if ($value === null) { - return ''; + $regexParts[] = $isVariable === 1 ? '([^/]+)' : preg_quote($segment, '#'); } - $encoded = collect(Arr::wrap($value))->map($this->encodeValue(...)); + $pattern = '#^'.implode('', $regexParts).'$#'; - return match ($part['operator']) { - '', '+' => $encoded->implode(','), - '#' => '#'.$encoded->implode(','), - '.' => '.'.$encoded->implode('.'), - '/' => '/'.$encoded->implode('/'), - default => $encoded->implode(','), - }; - } - - /** - * @param array{name: string, operator: string, names: list, exploded: bool} $part - * @return list - */ - private function partToRegExp(array $part): array - { - collect($part['names'])->each( - fn (string $varName) => $this->validateLength($varName, self::MAX_VARIABLE_LENGTH, 'Variable name') - ); - - if (in_array($part['operator'], ['?', '&'], true)) { - return array_values( - collect($part['names']) - ->map(fn (string $name, int $i): array => [ - 'pattern' => '(?:'.($i === 0 ? '[?&]' : '&').preg_quote($name, '#').'=([^&]*))?', - 'name' => $name, - 'optional' => true, - ]) - ->all() - ); - } - - $pattern = match ($part['operator']) { - '' => $part['exploded'] ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)', - '+', '#' => '(.+)', - '.' => '\\.([^/,]+)', - '/' => '/'.($part['exploded'] ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)'), - default => '([^/]+)', - }; + $this->validateLength($pattern, self::MAX_REGEX_LENGTH, 'Generated regex pattern'); - return [['pattern' => $pattern, 'name' => $part['name']]]; + return $pattern; } } diff --git a/tests/Unit/Resources/ResourceTemplateTest.php b/tests/Unit/Resources/ResourceTemplateTest.php index 13358f97..d8af883c 100644 --- a/tests/Unit/Resources/ResourceTemplateTest.php +++ b/tests/Unit/Resources/ResourceTemplateTest.php @@ -21,7 +21,6 @@ public function handle(Request $request): Response }; expect($resource->uri())->toBe('file://users/{userId}/files/{fileId}') - ->and($resource->uriTemplate()->getVariableNames())->toBe(['userId', 'fileId']) ->and($resource)->toBeInstanceOf(ResourceTemplate::class); }); @@ -110,8 +109,7 @@ public function handle(Request $request): Response } }; - expect($resource->uriTemplate()->getVariableNames())->toBe(['id']) - ->and($resource->uriTemplate()->match('file://resource/123'))->not->toBeNull() + expect($resource->uriTemplate()->match('file://resource/123'))->not->toBeNull() ->and($resource->uriTemplate()->match('file://resource/abc'))->toBe(['id' => 'abc']); }); @@ -213,6 +211,5 @@ public function handle(Request $request): Response $content = $response->content()->toResource($template); expect($content['text'])->toBe('API v2: User alice - Post hello-world') - ->and($template->uriTemplate()->getVariableNames())->toBe(['version', 'userId', 'postId']) ->and($template->uri())->toBe('file://api/{version}/users/{userId}/posts/{postId}'); }); diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php index 128462fc..3c3542eb 100644 --- a/tests/Unit/Support/UriTemplateTest.php +++ b/tests/Unit/Support/UriTemplateTest.php @@ -2,136 +2,21 @@ use Laravel\Mcp\Support\UriTemplate; -describe('UriTemplate::isTemplate', function (): void { - it('returns true for strings containing template expressions', function (): void { - expect(UriTemplate::isTemplate('{foo}'))->toBeTrue() - ->and(UriTemplate::isTemplate('/users/{id}'))->toBeTrue() - ->and(UriTemplate::isTemplate('http://example.com/{path}/{file}'))->toBeTrue() - ->and(UriTemplate::isTemplate('/search{?q,limit}'))->toBeTrue(); +describe('UriTemplate validation', function (): void { + it('requires a valid URI scheme', function (): void { + expect(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('/invalid/no-scheme/{id}')) + ->toThrow(InvalidArgumentException::class, 'Invalid URI template: must be a valid URI template with at least one placeholder.'); }); - it('returns false for strings without template expressions', function (): void { - expect(UriTemplate::isTemplate(''))->toBeFalse() - ->and(UriTemplate::isTemplate('plain string'))->toBeFalse() - ->and(UriTemplate::isTemplate('http://example.com/foo/bar'))->toBeFalse() - ->and(UriTemplate::isTemplate('{}'))->toBeFalse() - ->and(UriTemplate::isTemplate('{ }'))->toBeFalse(); + it('requires at least one placeholder', function (): void { + expect(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('file://path/without/placeholder')) + ->toThrow(InvalidArgumentException::class, 'Invalid URI template: must be a valid URI template with at least one placeholder.'); }); -}); - -describe('UriTemplate simple expansion', function (): void { - it('expands simple string variables', function (): void { - $template = new UriTemplate('http://example.com/users/{username}'); - - expect($template->expand(['username' => 'fred']))->toBe('http://example.com/users/fred') - ->and($template->getVariableNames())->toBe(['username']); - }); - - it('handles multiple variables', function (): void { - $template = new UriTemplate('{x,y}'); - - expect($template->expand(['x' => '1024', 'y' => '768']))->toBe('1024,768') - ->and($template->getVariableNames())->toBe(['x', 'y']); - }); - - it('encodes reserved characters', function (): void { - $template = new UriTemplate('{var}'); - expect($template->expand(['var' => 'value with spaces']))->toBe('value%20with%20spaces'); - }); -}); - -describe('UriTemplate reserved expansion (+)', function (): void { - it('does not encode reserved characters', function (): void { - $template = new UriTemplate('{+path}/here'); - - expect($template->expand(['path' => '/foo/bar']))->toBe('%2Ffoo%2Fbar/here') - ->and($template->getVariableNames())->toBe(['path']); - }); - - it('handles arrays', function (): void { - $template = new UriTemplate('{+list}'); - - expect($template->expand(['list' => ['red', 'green', 'blue']]))->toBe('red,green,blue'); - }); -}); - -describe('UriTemplate fragment expansion (#)', function (): void { - it('adds # prefix', function (): void { - $template = new UriTemplate('X{#var}'); - - expect($template->expand(['var' => '/test']))->toBe('X#%2Ftest') - ->and($template->getVariableNames())->toBe(['var']); - }); - - it('handles arrays', function (): void { - $template = new UriTemplate('X{#list}'); - - expect($template->expand(['list' => ['red', 'green', 'blue']]))->toBe('X#red,green,blue'); - }); -}); - -describe('UriTemplate label expansion (.)', function (): void { - it('adds . prefix', function (): void { - $template = new UriTemplate('X{.var}'); - - expect($template->expand(['var' => 'test']))->toBe('X.test') - ->and($template->getVariableNames())->toBe(['var']); - }); - - it('handles arrays', function (): void { - $template = new UriTemplate('X{.list}'); - - expect($template->expand(['list' => ['red', 'green', 'blue']]))->toBe('X.red.green.blue'); - }); -}); - -describe('UriTemplate path expansion (/)', function (): void { - it('adds / prefix', function (): void { - $template = new UriTemplate('X{/var}'); - - expect($template->expand(['var' => 'test']))->toBe('X/test') - ->and($template->getVariableNames())->toBe(['var']); - }); - - it('handles arrays', function (): void { - $template = new UriTemplate('X{/list}'); - - expect($template->expand(['list' => ['red', 'green', 'blue']]))->toBe('X/red/green/blue'); - }); -}); - -describe('UriTemplate query expansion (?)', function (): void { - it('adds ? prefix and name=value format', function (): void { - $template = new UriTemplate('X{?var}'); - - expect($template->expand(['var' => 'test']))->toBe('X?var=test') - ->and($template->getVariableNames())->toBe(['var']); - }); - - it('handles multiple parameters', function (): void { - $template = new UriTemplate('/search{?q,page,limit}'); - - expect($template->expand(['q' => 'test', 'page' => '1', 'limit' => '10'])) - ->toBe('/search?q=test&page=1&limit=10') - ->and($template->getVariableNames())->toBe(['q', 'page', 'limit']); - }); - - it('handles arrays', function (): void { - $template = new UriTemplate('/search{?tags*}'); - - expect($template->expand(['tags' => ['nodejs', 'typescript', 'testing']])) - ->toBe('/search?tags=nodejs,typescript,testing') - ->and($template->getVariableNames())->toBe(['tags']); - }); -}); - -describe('UriTemplate form continuation expansion (&)', function (): void { - it('adds & prefix and name=value format', function (): void { - $template = new UriTemplate('X{&var}'); - - expect($template->expand(['var' => 'test']))->toBe('X&var=test') - ->and($template->getVariableNames())->toBe(['var']); + it('accepts valid URI templates', function (): void { + expect(new UriTemplate('file://users/{id}'))->toBeInstanceOf(UriTemplate::class) + ->and(new UriTemplate('http://api.example.com/{endpoint}'))->toBeInstanceOf(UriTemplate::class) + ->and(new UriTemplate('https://example.com/path/{var}'))->toBeInstanceOf(UriTemplate::class); }); }); @@ -143,253 +28,131 @@ }); it('extracts multiple variables', function (): void { - $template = new UriTemplate('/users/{username}/posts/{postId}'); + $template = new UriTemplate('file://users/{username}/posts/{postId}'); - expect($template->match('/users/fred/posts/123'))->toBe(['username' => 'fred', 'postId' => '123']); + expect($template->match('file://users/fred/posts/123'))->toBe(['username' => 'fred', 'postId' => '123']); }); it('returns null for non-matching URIs', function (): void { - $template = new UriTemplate('/users/{username}'); + $template = new UriTemplate('file://users/{username}'); - expect($template->match('/posts/123'))->toBeNull(); - }); - - it('handles exploded arrays', function (): void { - $template = new UriTemplate('{/list*}'); - - expect($template->match('/red,green,blue'))->toBe(['list' => ['red', 'green', 'blue']]); + expect($template->match('file://posts/123'))->toBeNull(); }); it('matches nested path segments', function (): void { - $template = new UriTemplate('/api/{version}/{resource}/{id}'); + $template = new UriTemplate('http://api.example.com/{version}/{resource}/{id}'); - expect($template->match('/api/v1/users/123'))->toBe([ + expect($template->match('http://api.example.com/v1/users/123'))->toBe([ 'version' => 'v1', 'resource' => 'users', 'id' => '123', ]); }); - it('matches query parameters', function (): void { - $template = new UriTemplate('/search{?q}'); - - expect($template->match('/search?q=test'))->toBe(['q' => 'test']); - }); - - it('matches multiple query parameters', function (): void { - $template = new UriTemplate('/search{?q,page}'); - - expect($template->match('/search?q=test&page=1'))->toBe(['q' => 'test', 'page' => '1']); - }); - it('rejects partial matches', function (): void { - $template = new UriTemplate('/users/{id}'); - - expect($template->match('/users/123/extra'))->toBeNull() - ->and($template->match('/users'))->toBeNull(); - }); - - it('matches label expansion patterns', function (): void { - $template = new UriTemplate('X{.var}'); - - expect($template->match('X.test'))->toBe(['var' => 'test']) - ->and($template->match('Xtest'))->toBeNull(); - }); - - it('matches fragment expansion patterns', function (): void { - $template = new UriTemplate('X{#var}'); + $template = new UriTemplate('file://users/{id}'); - expect($template->match('X#test/value'))->toBe(['var' => '#test/value']); - }); - - it('matches reserved expansion patterns', function (): void { - $template = new UriTemplate('{+path}'); - - expect($template->match('/foo/bar'))->toBe(['path' => '/foo/bar']); - }); - - it('matches URIs with optional query parameters omitted', function (): void { - $template = new UriTemplate('/users{?cursor}'); - - expect($template->match('/users'))->toBe([]) - ->and($template->match('/users?cursor=abc123'))->toBe(['cursor' => 'abc123']); + expect($template->match('file://users/123/extra'))->toBeNull() + ->and($template->match('file://users'))->toBeNull(); }); +}); - it('matches URIs with some optional query parameters provided', function (): void { - $template = new UriTemplate('/search{?q,page,limit}'); +describe('UriTemplate simplified behavior', function (): void { + it('matches variables between slashes', function (): void { + $template = new UriTemplate('file://users/{userId}/posts/{postId}'); - expect($template->match('/search?q=test&page=1&limit=10'))->toBe([ - 'q' => 'test', - 'page' => '1', - 'limit' => '10', + expect($template->match('file://users/123/posts/456'))->toBe([ + 'userId' => '123', + 'postId' => '456', ]) - ->and($template->match('/search?q=test'))->toBe(['q' => 'test']) - ->and($template->match('/search'))->toBe([]); + ->and($template->match('file://users/123/posts'))->toBeNull() + ->and($template->match('file://users/123/posts/456/extra'))->toBeNull(); }); - it('matches query parameters in template order', function (): void { - $template = new UriTemplate('/api{?a,b}'); + it('does not match variables across slashes', function (): void { + $template = new UriTemplate('file://files/{path}'); - expect($template->match('/api?a=1&b=2'))->toBe(['a' => '1', 'b' => '2']); + expect($template->match('file://files/foo/bar'))->toBeNull() + ->and($template->match('file://files/simple.txt'))->toBe(['path' => 'simple.txt']); }); }); describe('UriTemplate edge cases', function (): void { - it('handles empty variables', function (): void { - $template = new UriTemplate('{empty}'); - - expect($template->expand([]))->toBe('') - ->and($template->expand(['empty' => '']))->toBe(''); - }); - - it('handles undefined variables', function (): void { - $template = new UriTemplate('{a}{b}{c}'); - - expect($template->expand(['b' => '2']))->toBe('2'); - }); - - it('handles special characters in variable names', function (): void { - $template = new UriTemplate('{$var_name}'); - - expect($template->expand(['$var_name' => 'value']))->toBe('value'); - }); - - it('returns empty string for multiple null variables', function (): void { - $template = new UriTemplate('{x,y,z}'); - - expect($template->expand([]))->toBe('') - ->and($template->expand(['other' => 'value']))->toBe(''); - }); - - it('handles multiple variables with array values', function (): void { - $template = new UriTemplate('{x,y}'); - - expect($template->expand(['x' => ['a', 'b'], 'y' => 'c']))->toBe('a,c') - ->and($template->expand(['x' => ['first'], 'y' => ['second']]))->toBe('first,second'); - }); - - it('handles nested path segments', function (): void { - $template = new UriTemplate('/api/{version}/{resource}/{id}'); - - expect($template->expand(['version' => 'v1', 'resource' => 'users', 'id' => '123'])) - ->toBe('/api/v1/users/123') - ->and($template->getVariableNames())->toBe(['version', 'resource', 'id']); - }); - - it('handles repeated operators', function (): void { - $template = new UriTemplate('{?a}{?b}{?c}'); - - expect($template->expand(['a' => '1', 'b' => '2', 'c' => '3']))->toBe('?a=1&b=2&c=3') - ->and($template->getVariableNames())->toBe(['a', 'b', 'c']); - }); - - it('handles overlapping variable names', function (): void { - $template = new UriTemplate('{var}{vara}'); - - expect($template->expand(['var' => '1', 'vara' => '2']))->toBe('12') - ->and($template->getVariableNames())->toBe(['var', 'vara']); - }); - it('handles empty segments', function (): void { - $template = new UriTemplate('///{a}////{b}////'); + $template = new UriTemplate('file://////{a}////{b}////'); - expect($template->expand(['a' => '1', 'b' => '2']))->toBe('///1////2////') - ->and($template->match('///1////2////'))->toBe(['a' => '1', 'b' => '2']) - ->and($template->getVariableNames())->toBe(['a', 'b']); + expect($template->match('file://////1////2////'))->toBe(['a' => '1', 'b' => '2']); }); }); describe('UriTemplate security', function (): void { it('handles extremely long input strings', function (): void { $longString = str_repeat('x', 100000); - $template = new UriTemplate('/api/{param}'); + $template = new UriTemplate('http://api.example.com/{param}'); - expect($template->expand(['param' => $longString]))->toBe('/api/'.$longString) - ->and($template->match('/api/'.$longString))->toBe(['param' => $longString]); + expect($template->match('http://api.example.com/'.$longString))->toBe(['param' => $longString]); }); it('throws when template exceeds maximum length', function (): void { $longTemplate = str_repeat('x', 1000001); expect(fn (): UriTemplate => new UriTemplate($longTemplate)) - ->toThrow(InvalidArgumentException::class, 'Template exceeds maximum length'); + ->toThrow(InvalidArgumentException::class, 'Template exceeds the maximum length'); }); it('throws when URI exceeds maximum length', function (): void { - $template = new UriTemplate('/api/{param}'); - $longUri = '/api/'.str_repeat('x', 1000001); + $template = new UriTemplate('http://api.example.com/{param}'); + $longUri = 'http://api.example.com/'.str_repeat('x', 1000001); expect(fn (): ?array => $template->match($longUri)) - ->toThrow(InvalidArgumentException::class, 'URI exceeds maximum length'); + ->toThrow(InvalidArgumentException::class, 'URI exceeds the maximum length'); }); it('throws when template has too many expressions', function (): void { - $tooManyExpressions = str_repeat('{a}', 10001); + $tooManyExpressions = 'http://example.com/'.str_repeat('{a}', 10001); expect(fn (): UriTemplate => new UriTemplate($tooManyExpressions)) ->toThrow(InvalidArgumentException::class, 'Template contains too many expressions'); }); - it('handles deeply nested template expressions', function (): void { - $template = new UriTemplate(str_repeat('{a}{b}{c}{d}{e}{f}{g}{h}{i}{j}', 1000)); - - expect(fn (): string => $template->expand([ - 'a' => '1', 'b' => '2', 'c' => '3', 'd' => '4', 'e' => '5', - 'f' => '6', 'g' => '7', 'h' => '8', 'i' => '9', 'j' => '0', - ]))->not->toThrow(Exception::class); - }); - it('throws for unclosed template expressions', function (): void { - expect(fn (): UriTemplate => new UriTemplate('{unclosed')) + expect(fn (): UriTemplate => new UriTemplate('http://example.com/{unclosed')) ->toThrow(InvalidArgumentException::class); }); - it('handles empty template expressions', function (): void { - expect(fn (): UriTemplate => new UriTemplate('{}'))->not->toThrow(Exception::class) - ->and(fn (): UriTemplate => new UriTemplate('{,}'))->not->toThrow(Exception::class); - }); - it('handles pathological regex patterns', function (): void { - $template = new UriTemplate('/api/{param}'); - $input = '/api/'.str_repeat('a', 100000); + $template = new UriTemplate('http://api.example.com/{param}'); + $input = 'http://api.example.com/'.str_repeat('a', 100000); expect(fn (): ?array => $template->match($input))->not->toThrow(Exception::class); }); it('handles invalid UTF-8 sequences', function (): void { - $template = new UriTemplate('/api/{param}'); + $template = new UriTemplate('http://api.example.com/{param}'); $invalidUtf8 = '���'; - expect(fn (): string => $template->expand(['param' => $invalidUtf8]))->not->toThrow(Exception::class) - ->and(fn (): ?array => $template->match('/api/'.$invalidUtf8))->not->toThrow(Exception::class); + expect(fn (): ?array => $template->match('http://api.example.com/'.$invalidUtf8))->not->toThrow(Exception::class); }); it('handles template/URI length mismatches', function (): void { - $template = new UriTemplate('/api/{param}'); + $template = new UriTemplate('http://api.example.com/{param}'); - expect($template->match('/api/'))->toBeNull() - ->and($template->match('/api'))->toBeNull() - ->and($template->match('/api/value/extra'))->toBeNull(); + expect($template->match('http://api.example.com/'))->toBeNull() + ->and($template->match('http://api.example.com'))->toBeNull() + ->and($template->match('http://api.example.com/value/extra'))->toBeNull(); }); it('handles maximum template expression limit', function (): void { - $expressions = str_repeat('{param}', 10000); + $expressions = 'http://example.com/'.str_repeat('{param}', 10000); expect(fn (): UriTemplate => new UriTemplate($expressions))->not->toThrow(Exception::class); }); - - it('handles maximum variable name length', function (): void { - $longName = str_repeat('a', 10000); - $template = new UriTemplate('{'.$longName.'}'); - - expect(fn (): string => $template->expand([$longName => 'value']))->not->toThrow(Exception::class); - }); }); describe('UriTemplate::__toString', function (): void { it('casts to string', function (): void { - $template = new UriTemplate('/users/{id}'); + $template = new UriTemplate('file://users/{id}'); - expect((string) $template)->toBe('/users/{id}'); + expect((string) $template)->toBe('file://users/{id}'); }); }); From 941dc2b358e7484fc9c6ab5c7ae0cf75240637c1 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 22:41:40 +0530 Subject: [PATCH 17/33] Refactor --- src/Server/ServerContext.php | 3 ++- tests/Fixtures/ExampleResourceTemplate.php | 29 ++++++++++++++++++++++ tests/Unit/Methods/ListResourcesTest.php | 26 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/ExampleResourceTemplate.php diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index d1520e00..46b2ee5a 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -47,7 +47,8 @@ public function tools(): Collection */ public function resources(): Collection { - return collect($this->resources)->filter(fn ($resource): bool => ! ($resource instanceof ResourceTemplate)) + return collect($this->resources) + ->filter(fn ($resource): bool => ! ($resource instanceof ResourceTemplate) && ! (is_string($resource) && is_subclass_of($resource, ResourceTemplate::class))) ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) ? Container::getInstance()->make($resourceClass) : $resourceClass) diff --git a/tests/Fixtures/ExampleResourceTemplate.php b/tests/Fixtures/ExampleResourceTemplate.php new file mode 100644 index 00000000..6a1cd542 --- /dev/null +++ b/tests/Fixtures/ExampleResourceTemplate.php @@ -0,0 +1,29 @@ +get('id'); + + return Response::text("Example resource: {$id}"); + } +} diff --git a/tests/Unit/Methods/ListResourcesTest.php b/tests/Unit/Methods/ListResourcesTest.php index c1de1f5a..2b596715 100644 --- a/tests/Unit/Methods/ListResourcesTest.php +++ b/tests/Unit/Methods/ListResourcesTest.php @@ -267,3 +267,29 @@ public function handle(Request $request): Response 'resources' => [], ]); }); + +it('excludes template class strings from list', function (): void { + $staticResource = $this->makeResource(); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [], + resources: [Tests\Fixtures\ExampleResourceTemplate::class, $staticResource], + prompts: [], + ); + + $request = new JsonRpcRequest(id: 1, method: 'resources/list', params: []); + $listResources = new ListResources; + $response = $listResources->handle($request, $context); + + $payload = $response->toArray(); + + expect($payload['result']['resources'])->toHaveCount(1) + ->and($payload['result']['resources'][0]['name'])->toBe($staticResource->name()); +}); From 39e618e079d0059e1a9f6f6dc0346cec8067fd0a Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 25 Nov 2025 23:46:29 +0530 Subject: [PATCH 18/33] Refactor --- src/Server/Contracts/SupportsURITemplate.php | 12 +++++ src/Server/Methods/ReadResource.php | 6 +-- src/Server/Resource.php | 23 +++++++-- src/Server/ResourceTemplate.php | 40 ---------------- src/Server/ServerContext.php | 9 ++-- stubs/resource-template.stub | 5 +- tests/Fixtures/ExampleResourceTemplate.php | 5 +- .../Methods/ListResourceTemplatesTest.php | 8 ++-- tests/Unit/Methods/ListResourcesTest.php | 10 ++-- tests/Unit/Methods/ReadResourceTest.php | 27 ++++++----- tests/Unit/Resources/ResourceTemplateTest.php | 22 ++++----- tests/Unit/Support/UriTemplateTest.php | 48 +++++++++---------- 12 files changed, 102 insertions(+), 113 deletions(-) create mode 100644 src/Server/Contracts/SupportsURITemplate.php delete mode 100644 src/Server/ResourceTemplate.php diff --git a/src/Server/Contracts/SupportsURITemplate.php b/src/Server/Contracts/SupportsURITemplate.php new file mode 100644 index 00000000..0d592d05 --- /dev/null +++ b/src/Server/Contracts/SupportsURITemplate.php @@ -0,0 +1,12 @@ +resources()->first(fn (Resource $resource): bool => $resource->uri() === $uri) - ?? $context->resourceTemplates()->first(fn (ResourceTemplate $template): bool => ! is_null($template->uriTemplate()->match($uri))); + ?? $context->resourceTemplates()->first(fn (Resource $template): bool => $template instanceof SupportsURITemplate && ! is_null($template->uriTemplate()->match($uri))); if (! $resource) { throw new JsonRpcException("Resource [{$uri}] not found.", -32002, $request->id); @@ -59,7 +59,7 @@ protected function invokeResource(Resource $resource, string $uri): mixed { $container = Container::getInstance(); - if ($resource instanceof ResourceTemplate) { + if ($resource instanceof SupportsURITemplate) { $variables = $resource->uriTemplate()->match($uri) ?? []; $request = $container->make(Request::class); diff --git a/src/Server/Resource.php b/src/Server/Resource.php index b65492fd..65a0c904 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -5,6 +5,7 @@ namespace Laravel\Mcp\Server; use Illuminate\Support\Str; +use Laravel\Mcp\Server\Contracts\SupportsURITemplate; abstract class Resource extends Primitive { @@ -14,6 +15,10 @@ abstract class Resource extends Primitive public function uri(): string { + if ($this instanceof SupportsURITemplate) { + return (string) $this->uriTemplate(); + } + return $this->uri !== '' ? $this->uri : 'file://resources/'.Str::kebab(class_basename($this)); @@ -39,20 +44,28 @@ public function toMethodCall(): array * name: string, * title: string, * description: string, - * uri: string, + * uri?: string, + * uriTemplate? :string, * mimeType: string, * _meta?: array * } */ public function toArray(): array { - // @phpstan-ignore return.type - return $this->mergeMeta([ + $result = [ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), - 'uri' => $this->uri(), 'mimeType' => $this->mimeType(), - ]); + ]; + + if ($this instanceof SupportsURITemplate) { + $result['uriTemplate'] = (string) $this->uriTemplate(); + } else { + $result['uri'] = $this->uri(); + } + + // @phpstan-ignore return.type + return $this->mergeMeta($result); } } diff --git a/src/Server/ResourceTemplate.php b/src/Server/ResourceTemplate.php deleted file mode 100644 index dca8988c..00000000 --- a/src/Server/ResourceTemplate.php +++ /dev/null @@ -1,40 +0,0 @@ -uriTemplate(); - } - - /** - * @return array{ - * name: string, - * title: string, - * description: string, - * uri?: string, - * uriTemplate: string, - * mimeType: string, - * _meta?: array - * } - */ - public function toArray(): array - { - // @phpstan-ignore return.type - return $this->mergeMeta([ - 'name' => $this->name(), - 'title' => $this->title(), - 'description' => $this->description(), - 'uriTemplate' => (string) $this->uriTemplate(), - 'mimeType' => $this->mimeType(), - ]); - } -} diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 46b2ee5a..495a9059 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -6,6 +6,7 @@ use Illuminate\Container\Container; use Illuminate\Support\Collection; +use Laravel\Mcp\Server\Contracts\SupportsURITemplate; class ServerContext { @@ -48,7 +49,7 @@ public function tools(): Collection public function resources(): Collection { return collect($this->resources) - ->filter(fn ($resource): bool => ! ($resource instanceof ResourceTemplate) && ! (is_string($resource) && is_subclass_of($resource, ResourceTemplate::class))) + ->filter(fn ($resource): bool => ! ($resource instanceof SupportsURITemplate) && ! (is_string($resource) && is_subclass_of($resource, SupportsURITemplate::class))) ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) ? Container::getInstance()->make($resourceClass) : $resourceClass) @@ -56,16 +57,16 @@ public function resources(): Collection } /** - * @return Collection + * @return Collection */ public function resourceTemplates(): Collection { return collect($this->resources) - ->filter(fn ($resource): bool => ($resource instanceof ResourceTemplate) || (is_string($resource) && is_subclass_of($resource, ResourceTemplate::class))) + ->filter(fn ($resource): bool => ($resource instanceof SupportsURITemplate) || (is_string($resource) && is_subclass_of($resource, SupportsURITemplate::class))) ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) ? Container::getInstance()->make($resourceClass) : $resourceClass) - ->filter(fn (ResourceTemplate $resource): bool => $resource->eligibleForRegistration()); + ->filter(fn (Resource $resource): bool => $resource->eligibleForRegistration()); } /** diff --git a/stubs/resource-template.stub b/stubs/resource-template.stub index 76a4e19c..e9170352 100644 --- a/stubs/resource-template.stub +++ b/stubs/resource-template.stub @@ -4,10 +4,11 @@ namespace {{ namespace }}; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\ResourceTemplate; +use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; -class {{ class }} extends ResourceTemplate +class {{ class }} extends Resource implements SupportsURITemplate { /** * The resource's description. diff --git a/tests/Fixtures/ExampleResourceTemplate.php b/tests/Fixtures/ExampleResourceTemplate.php index 6a1cd542..326272f6 100644 --- a/tests/Fixtures/ExampleResourceTemplate.php +++ b/tests/Fixtures/ExampleResourceTemplate.php @@ -6,10 +6,11 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\ResourceTemplate; +use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; -class ExampleResourceTemplate extends ResourceTemplate +class ExampleResourceTemplate extends Resource implements SupportsURITemplate { protected string $description = 'Example resource template for testing'; diff --git a/tests/Unit/Methods/ListResourceTemplatesTest.php b/tests/Unit/Methods/ListResourceTemplatesTest.php index f87fd31d..86cb7bda 100644 --- a/tests/Unit/Methods/ListResourceTemplatesTest.php +++ b/tests/Unit/Methods/ListResourceTemplatesTest.php @@ -2,9 +2,9 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Contracts\SupportsURITemplate; use Laravel\Mcp\Server\Methods\ListResourceTemplates; use Laravel\Mcp\Server\Resource; -use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Support\UriTemplate; @@ -21,7 +21,7 @@ public function handle(): Response }; // Create a template resource - $templateResource = new class extends ResourceTemplate + $templateResource = new class extends Resource implements SupportsURITemplate { protected string $mimeType = 'text/plain'; @@ -108,8 +108,8 @@ public function handle(): Response ->and($payload['result']['resourceTemplates'])->toBeEmpty(); }); -it('includes template metadata in listing', function (): void { - $templateResource = new class extends ResourceTemplate +it('includes template metadata in the listing', function (): void { + $templateResource = new class extends Resource implements SupportsURITemplate { protected string $name = 'user-file'; diff --git a/tests/Unit/Methods/ListResourcesTest.php b/tests/Unit/Methods/ListResourcesTest.php index 2b596715..6d2bb66f 100644 --- a/tests/Unit/Methods/ListResourcesTest.php +++ b/tests/Unit/Methods/ListResourcesTest.php @@ -4,9 +4,9 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Contracts\SupportsURITemplate; use Laravel\Mcp\Server\Methods\ListResources; use Laravel\Mcp\Server\Resource; -use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -140,7 +140,7 @@ public function shouldRegister(Request $request): bool }); it('excludes resource templates from list', function (): void { - $template = new class extends ResourceTemplate + $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -180,7 +180,7 @@ public function handle(Request $request): Response it('returns only static resources when both templates and static resources exist', function (): void { $staticResource = $this->makeResource(); - $template = new class extends ResourceTemplate + $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -218,7 +218,7 @@ public function handle(Request $request): Response }); it('returns empty list when only templates are registered', function (): void { - $template1 = new class extends ResourceTemplate + $template1 = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -231,7 +231,7 @@ public function handle(Request $request): Response } }; - $template2 = new class extends ResourceTemplate + $template2 = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index b818e50c..333a9abb 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -5,9 +5,10 @@ use Illuminate\Container\Container; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Contracts\SupportsURITemplate; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\ReadResource; -use Laravel\Mcp\Server\ResourceTemplate; +use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -86,7 +87,7 @@ }); it('reads resource template by matching URI pattern', function (): void { - $template = new class extends ResourceTemplate + $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -120,7 +121,7 @@ public function handle(Request $request): Response }); it('returns actual requested URI in response, not the template pattern', function (): void { - $template = new class extends ResourceTemplate + $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -156,7 +157,7 @@ public function handle(Request $request): Response it('extracts single variable from URI and passes to handler', function (): void { $capturedUserId = null; - $template = new class($capturedUserId) extends ResourceTemplate + $template = new class($capturedUserId) extends Resource implements SupportsURITemplate { public function __construct(private &$capturedValue) {} @@ -192,7 +193,7 @@ public function handle(Request $request): Response it('extracts multiple variables from URI and passes to handler', function (): void { $capturedVars = null; - $template = new class($capturedVars) extends ResourceTemplate + $template = new class($capturedVars) extends Resource implements SupportsURITemplate { public function __construct(private &$capturedValues) {} @@ -234,7 +235,7 @@ public function handle(Request $request): Response it('includes uri parameter along with extracted variables in request', function (): void { $capturedAll = null; - $template = new class($capturedAll) extends ResourceTemplate + $template = new class($capturedAll) extends Resource implements SupportsURITemplate { public function __construct(private &$capturedData) {} @@ -275,7 +276,7 @@ public function handle(Request $request): Response $capturedMeta = null; $capturedArguments = null; - $template = new class($capturedSessionId, $capturedMeta, $capturedArguments) extends ResourceTemplate + $template = new class($capturedSessionId, $capturedMeta, $capturedArguments) extends Resource implements SupportsURITemplate { public function __construct( private &$sessionIdRef, @@ -334,7 +335,7 @@ public function handle(Request $request): Response it('template handler receives variables via request get method', function (): void { $accessMethodWorks = false; - $template = new class($accessMethodWorks) extends ResourceTemplate + $template = new class($accessMethodWorks) extends Resource implements SupportsURITemplate { public function __construct(private &$testResult) {} @@ -373,7 +374,7 @@ public function handle(Request $request): Response it('tries static resources before template matching', function (): void { $staticResource = $this->makeResource('Static resource content'); - $template = new class extends ResourceTemplate + $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -407,7 +408,7 @@ public function handle(Request $request): Response }); it('returns first matching template when multiple templates exist', function (): void { - $template1 = new class extends ResourceTemplate + $template1 = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -420,7 +421,7 @@ public function handle(Request $request): Response } }; - $template2 = new class extends ResourceTemplate + $template2 = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -457,7 +458,7 @@ public function handle(Request $request): Response $this->expectException(JsonRpcException::class); $this->expectExceptionMessage('Resource [file://posts/123] not found.'); - $template = new class extends ResourceTemplate + $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -537,7 +538,7 @@ public function handle(Request $request): Response $firstRequestVars = null; $secondRequestVars = null; - $template = new class($firstRequestVars, $secondRequestVars) extends ResourceTemplate + $template = new class($firstRequestVars, $secondRequestVars) extends Resource implements SupportsURITemplate { public function __construct(private &$firstRef, private &$secondRef) {} diff --git a/tests/Unit/Resources/ResourceTemplateTest.php b/tests/Unit/Resources/ResourceTemplateTest.php index d8af883c..c412b606 100644 --- a/tests/Unit/Resources/ResourceTemplateTest.php +++ b/tests/Unit/Resources/ResourceTemplateTest.php @@ -2,12 +2,12 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Contracts\SupportsURITemplate; use Laravel\Mcp\Server\Resource; -use Laravel\Mcp\Server\ResourceTemplate; use Laravel\Mcp\Support\UriTemplate; it('compiles URI template and extracts variable names', function (): void { - $resource = new class extends ResourceTemplate + $resource = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -21,11 +21,11 @@ public function handle(Request $request): Response }; expect($resource->uri())->toBe('file://users/{userId}/files/{fileId}') - ->and($resource)->toBeInstanceOf(ResourceTemplate::class); + ->and($resource)->toBeInstanceOf(SupportsURITemplate::class); }); it('matches URIs against a template pattern', function (): void { - $resource = new class extends ResourceTemplate + $resource = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -46,7 +46,7 @@ public function handle(Request $request): Response }); it('extracts variables from matching URI', function (): void { - $resource = new class extends ResourceTemplate + $resource = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -69,7 +69,7 @@ public function handle(Request $request): Response }); it('handles template resource with extracted variables', function (): void { - $resource = new class extends ResourceTemplate + $resource = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -96,7 +96,7 @@ public function handle(Request $request): Response }); it('handles template with single variable', function (): void { - $resource = new class extends ResourceTemplate + $resource = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -114,7 +114,7 @@ public function handle(Request $request): Response }); it('handles complex URI templates with multiple path segments', function (): void { - $resource = new class extends ResourceTemplate + $resource = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -138,7 +138,7 @@ public function handle(Request $request): Response }); it('does not match URIs with different path structure', function (): void { - $resource = new class extends ResourceTemplate + $resource = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { @@ -167,11 +167,11 @@ public function handle(): Response } }; - expect($resource)->not->toBeInstanceOf(ResourceTemplate::class); + expect($resource)->not->toBeInstanceOf(SupportsURITemplate::class); }); it('end to end template reads uri extracts variables and returns response', function (): void { - $template = new class extends ResourceTemplate + $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php index 3c3542eb..8320f250 100644 --- a/tests/Unit/Support/UriTemplateTest.php +++ b/tests/Unit/Support/UriTemplateTest.php @@ -4,27 +4,27 @@ describe('UriTemplate validation', function (): void { it('requires a valid URI scheme', function (): void { - expect(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('/invalid/no-scheme/{id}')) + expect(fn (): UriTemplate => new UriTemplate('/invalid/no-scheme/{id}')) ->toThrow(InvalidArgumentException::class, 'Invalid URI template: must be a valid URI template with at least one placeholder.'); }); it('requires at least one placeholder', function (): void { - expect(fn (): \Laravel\Mcp\Support\UriTemplate => new UriTemplate('file://path/without/placeholder')) + expect(fn (): UriTemplate => new UriTemplate('file://path/without/placeholder')) ->toThrow(InvalidArgumentException::class, 'Invalid URI template: must be a valid URI template with at least one placeholder.'); }); it('accepts valid URI templates', function (): void { expect(new UriTemplate('file://users/{id}'))->toBeInstanceOf(UriTemplate::class) - ->and(new UriTemplate('http://api.example.com/{endpoint}'))->toBeInstanceOf(UriTemplate::class) + ->and(new UriTemplate('https://api.example.com/{endpoint}'))->toBeInstanceOf(UriTemplate::class) ->and(new UriTemplate('https://example.com/path/{var}'))->toBeInstanceOf(UriTemplate::class); }); }); describe('UriTemplate::match', function (): void { it('extracts variables from simple strings', function (): void { - $template = new UriTemplate('http://example.com/users/{username}'); + $template = new UriTemplate('https://example.com/users/{username}'); - expect($template->match('http://example.com/users/fred'))->toBe(['username' => 'fred']); + expect($template->match('https://example.com/users/fred'))->toBe(['username' => 'fred']); }); it('extracts multiple variables', function (): void { @@ -40,9 +40,9 @@ }); it('matches nested path segments', function (): void { - $template = new UriTemplate('http://api.example.com/{version}/{resource}/{id}'); + $template = new UriTemplate('https://api.example.com/{version}/{resource}/{id}'); - expect($template->match('http://api.example.com/v1/users/123'))->toBe([ + expect($template->match('https://api.example.com/v1/users/123'))->toBe([ 'version' => 'v1', 'resource' => 'users', 'id' => '123', @@ -88,12 +88,12 @@ describe('UriTemplate security', function (): void { it('handles extremely long input strings', function (): void { $longString = str_repeat('x', 100000); - $template = new UriTemplate('http://api.example.com/{param}'); + $template = new UriTemplate('https://api.example.com/{param}'); - expect($template->match('http://api.example.com/'.$longString))->toBe(['param' => $longString]); + expect($template->match('https://api.example.com/'.$longString))->toBe(['param' => $longString]); }); - it('throws when template exceeds maximum length', function (): void { + it('throws when the template exceeds the maximum length', function (): void { $longTemplate = str_repeat('x', 1000001); expect(fn (): UriTemplate => new UriTemplate($longTemplate)) @@ -101,49 +101,49 @@ }); it('throws when URI exceeds maximum length', function (): void { - $template = new UriTemplate('http://api.example.com/{param}'); - $longUri = 'http://api.example.com/'.str_repeat('x', 1000001); + $template = new UriTemplate('https://api.example.com/{param}'); + $longUri = 'https://api.example.com/'.str_repeat('x', 1000001); expect(fn (): ?array => $template->match($longUri)) ->toThrow(InvalidArgumentException::class, 'URI exceeds the maximum length'); }); - it('throws when template has too many expressions', function (): void { - $tooManyExpressions = 'http://example.com/'.str_repeat('{a}', 10001); + it('throws when the template has too many expressions', function (): void { + $tooManyExpressions = 'https://example.com/'.str_repeat('{a}', 10001); expect(fn (): UriTemplate => new UriTemplate($tooManyExpressions)) ->toThrow(InvalidArgumentException::class, 'Template contains too many expressions'); }); it('throws for unclosed template expressions', function (): void { - expect(fn (): UriTemplate => new UriTemplate('http://example.com/{unclosed')) + expect(fn (): UriTemplate => new UriTemplate('https://example.com/{unclosed')) ->toThrow(InvalidArgumentException::class); }); it('handles pathological regex patterns', function (): void { - $template = new UriTemplate('http://api.example.com/{param}'); - $input = 'http://api.example.com/'.str_repeat('a', 100000); + $template = new UriTemplate('https://api.example.com/{param}'); + $input = 'https://api.example.com/'.str_repeat('a', 100000); expect(fn (): ?array => $template->match($input))->not->toThrow(Exception::class); }); it('handles invalid UTF-8 sequences', function (): void { - $template = new UriTemplate('http://api.example.com/{param}'); + $template = new UriTemplate('https://api.example.com/{param}'); $invalidUtf8 = '���'; - expect(fn (): ?array => $template->match('http://api.example.com/'.$invalidUtf8))->not->toThrow(Exception::class); + expect(fn (): ?array => $template->match('https://api.example.com/'.$invalidUtf8))->not->toThrow(Exception::class); }); it('handles template/URI length mismatches', function (): void { - $template = new UriTemplate('http://api.example.com/{param}'); + $template = new UriTemplate('https://api.example.com/{param}'); - expect($template->match('http://api.example.com/'))->toBeNull() - ->and($template->match('http://api.example.com'))->toBeNull() - ->and($template->match('http://api.example.com/value/extra'))->toBeNull(); + expect($template->match('https://api.example.com/'))->toBeNull() + ->and($template->match('https://api.example.com'))->toBeNull() + ->and($template->match('https://api.example.com/value/extra'))->toBeNull(); }); it('handles maximum template expression limit', function (): void { - $expressions = 'http://example.com/'.str_repeat('{param}', 10000); + $expressions = 'https://example.com/'.str_repeat('{param}', 10000); expect(fn (): UriTemplate => new UriTemplate($expressions))->not->toThrow(Exception::class); }); From 6350fbf6265609e7de9c79d657465f9332fe849a Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 26 Nov 2025 00:19:14 +0530 Subject: [PATCH 19/33] Add More Test --- src/Request.php | 11 ++ src/Server/Methods/ReadResource.php | 30 ++-- tests/Unit/Methods/CallToolTest.php | 60 +++++++ tests/Unit/Methods/ReadResourceTest.php | 205 ++++++++++++++++++++++++ tests/Unit/RequestTest.php | 81 ++++++++++ 5 files changed, 374 insertions(+), 13 deletions(-) diff --git a/src/Request.php b/src/Request.php index 4ff25f13..2b1d04e4 100644 --- a/src/Request.php +++ b/src/Request.php @@ -30,6 +30,7 @@ public function __construct( protected array $arguments = [], protected ?string $sessionId = null, protected ?array $meta = null, + protected ?string $uri = null, ) { // } @@ -112,6 +113,11 @@ public function meta(): ?array return $this->meta; } + public function uri(): ?string + { + return $this->uri; + } + /** * @param array $arguments */ @@ -132,4 +138,9 @@ public function setMeta(?array $meta): void { $this->meta = $meta; } + + public function setUri(?string $uri): ?string + { + return $this->uri = $uri; + } } diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index a61a12a6..225210ee 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -37,8 +37,12 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat $request->id, ); - $resource = $context->resources()->first(fn (Resource $resource): bool => $resource->uri() === $uri) - ?? $context->resourceTemplates()->first(fn (Resource $template): bool => $template instanceof SupportsURITemplate && ! is_null($template->uriTemplate()->match($uri))); + $resource = $context->resources()->first( + fn (Resource $resource): bool => $resource->uri() === $uri, + $context->resourceTemplates()->first( + fn (Resource $template): bool => $template instanceof SupportsURITemplate && ! is_null($template->uriTemplate()->match($uri)), + ), + ); if (! $resource) { throw new JsonRpcException("Resource [{$uri}] not found.", -32002, $request->id); @@ -59,22 +63,22 @@ protected function invokeResource(Resource $resource, string $uri): mixed { $container = Container::getInstance(); + $request = $container->make(Request::class); + $request->setUri($uri); + if ($resource instanceof SupportsURITemplate) { $variables = $resource->uriTemplate()->match($uri) ?? []; + $request->merge($variables); + } - $request = $container->make(Request::class); - $container->instance(Request::class, $request->merge($variables)); + $container->instance(Request::class, $request); - try { - // @phpstan-ignore-next-line - return $container->call([$resource, 'handle']); - } finally { - $container->forgetInstance(Request::class); - } + try { + // @phpstan-ignore-next-line + return $container->call([$resource, 'handle']); + } finally { + $container->forgetInstance(Request::class); } - - // @phpstan-ignore-next-line - return Container::getInstance()->call([$resource, 'handle']); } protected function serializable(Resource $resource, string $uri): callable diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index 0536e3d2..4f34dd83 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -1,7 +1,11 @@ uriRef = $request->uri(); + + return Response::text('Test'); + } + + public function schema(JsonSchema $schema): array + { + return []; + } + }; + + $toolClass = $tool::class; + $this->instance($toolClass, $tool); + + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $tool->name(), + 'arguments' => [], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test', + serverVersion: '1.0.0', + instructions: '', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [$toolClass], + resources: [], + prompts: [], + ); + + $this->instance('mcp.request', $request->toRequest()); + + $method = new CallTool; + $method->handle($request, $context); + + expect($capturedUri)->toBeNull(); +}); diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index 333a9abb..692324cb 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -590,3 +590,208 @@ public function handle(Request $request): Response ->and($secondRequestVars)->not->toHaveKey('userId', '100') ->and($secondRequestVars)->not->toHaveKey('postId', '42'); }); + +it('sets uri on request when reading resource templates', function (): void { + $capturedUri = null; + + $template = new class($capturedUri) extends Resource implements SupportsURITemplate + { + public function __construct(private &$uriRef) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + $this->uriRef = $request->uri(); + + return Response::text('User data'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $requestedUri = 'file://users/42'; + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => $requestedUri] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($capturedUri)->toBe($requestedUri); +}); + +it('uri contains the actual requested uri, not the template pattern', function (): void { + $capturedUri = null; + + $template = new class($capturedUri) extends Resource implements SupportsURITemplate + { + public function __construct(private &$uriRef) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://posts/{postId}/comments/{commentId}'); + } + + public function handle(Request $request): Response + { + $this->uriRef = $request->uri(); + + return Response::text('Comment'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $requestedUri = 'file://posts/100/comments/5'; + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => $requestedUri] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($capturedUri)->toBe($requestedUri); +}); + +it('provides both uri and extracted variables in request for templates', function (): void { + $capturedData = null; + + $template = new class($capturedData) extends Resource implements SupportsURITemplate + { + public function __construct(private &$dataRef) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}/files/{fileId}'); + } + + public function handle(Request $request): Response + { + $this->dataRef = [ + 'uri' => $request->uri(), + 'userId' => $request->get('userId'), + 'fileId' => $request->get('fileId'), + ]; + + return Response::text('File data'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $requestedUri = 'file://users/123/files/document.pdf'; + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => $requestedUri] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($capturedData)->toBe([ + 'uri' => 'file://users/123/files/document.pdf', + 'userId' => '123', + 'fileId' => 'document.pdf', + ]); +}); + +it('uri is isolated between consecutive resource requests', function (): void { + $firstUri = null; + $secondUri = null; + $callCount = 0; + + $template = new class($firstUri, $secondUri, $callCount) extends Resource implements SupportsURITemplate + { + public function __construct(private &$firstRef, private &$secondRef, private &$count) {} + + public function uriTemplate(): UriTemplate + { + return new UriTemplate('file://users/{userId}'); + } + + public function handle(Request $request): Response + { + $this->count++; + if ($this->count === 1) { + $this->firstRef = $request->uri(); + } else { + $this->secondRef = $request->uri(); + } + + return Response::text('User data'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$template], + ]); + + $readResource = new ReadResource; + + $firstRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://users/100'] + ); + $readResource->handle($firstRequest, $context); + + $secondRequest = new JsonRpcRequest( + id: 2, + method: 'resources/read', + params: ['uri' => 'file://users/200'] + ); + $readResource->handle($secondRequest, $context); + + expect($firstUri)->toBe('file://users/100') + ->and($secondUri)->toBe('file://users/200'); +}); + +it('sets uri on request for static resources that accept request parameter', function (): void { + $capturedUri = null; + + $resource = new class($capturedUri) extends Resource + { + protected string $uri = 'file://static/resource'; + + protected string $mimeType = 'text/plain'; + + public function __construct(private &$uriRef) {} + + public function handle(Request $request): Response + { + $this->uriRef = $request->uri(); + + return Response::text('Static content'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$resource], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://static/resource'] + ); + + $readResource = new ReadResource; + $readResource->handle($jsonRpcRequest, $context); + + expect($capturedUri)->toBe('file://static/resource'); +}); diff --git a/tests/Unit/RequestTest.php b/tests/Unit/RequestTest.php index cee9a1e2..35f070b1 100644 --- a/tests/Unit/RequestTest.php +++ b/tests/Unit/RequestTest.php @@ -114,3 +114,84 @@ expect($closure)->toThrow(ValidationException::class); }); + +it('can get uri when set via constructor', function (): void { + $request = new Request( + arguments: ['name' => 'Alice'], + sessionId: 'session-123', + meta: ['key' => 'value'], + uri: 'file://resources/example' + ); + + expect($request->uri())->toBe('file://resources/example'); +}); + +it('returns null for uri when not set via constructor', function (): void { + $request = new Request( + arguments: ['name' => 'Alice'], + sessionId: 'session-123', + meta: ['key' => 'value'] + ); + + expect($request->uri())->toBeNull(); +}); + +it('returns null for uri when explicitly set to null in constructor', function (): void { + $request = new Request( + arguments: ['name' => 'Alice'], + uri: null + ); + + expect($request->uri())->toBeNull(); +}); + +it('can set uri using setUri method', function (): void { + $request = new Request(['name' => 'Alice']); + + $result = $request->setUri('file://resources/test'); + + expect($request->uri())->toBe('file://resources/test') + ->and($result)->toBe('file://resources/test'); +}); + +it('can update uri using setUri method', function (): void { + $request = new Request( + arguments: ['name' => 'Alice'], + uri: 'file://resources/original' + ); + + $request->setUri('file://resources/updated'); + + expect($request->uri())->toBe('file://resources/updated'); +}); + +it('can set uri to null using setUri method', function (): void { + $request = new Request( + arguments: ['name' => 'Alice'], + uri: 'file://resources/example' + ); + + $result = $request->setUri(null); + + expect($request->uri())->toBeNull() + ->and($result)->toBeNull(); +}); + +it('setUri returns the set value for method chaining', function (): void { + $request = new Request(['name' => 'Alice']); + + $returnedUri = $request->setUri('file://resources/test'); + + expect($returnedUri)->toBe('file://resources/test') + ->and($returnedUri)->toBe($request->uri()); +}); + +it('supports method chaining with merge and setUri', function (): void { + $request = new Request(['name' => 'Alice']); + + $request->merge(['age' => 30])->setUri('file://resources/test'); + + expect($request->uri())->toBe('file://resources/test') + ->and($request->get('name'))->toBe('Alice') + ->and($request->get('age'))->toBe(30); +}); From cd0c4a96f4826d107a3b450e2ae4f31ac9f1194b Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 26 Nov 2025 15:47:14 +0530 Subject: [PATCH 20/33] Add make:: method --- src/Server/Methods/ReadResource.php | 35 +++++++++++++++----------- src/Server/Resource.php | 13 +++------- src/Support/UriTemplate.php | 14 +++++++---- stubs/resource-template.stub | 2 +- tests/Unit/Support/UriTemplateTest.php | 30 ++++++++++++++++++++++ 5 files changed, 65 insertions(+), 29 deletions(-) diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index 225210ee..bfebd145 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -6,6 +6,7 @@ use Generator; use Illuminate\Container\Container; +use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -28,23 +29,25 @@ class ReadResource implements Method * @return Generator|JsonRpcResponse * * @throws JsonRpcException + * @throws BindingResolutionException */ public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse { - $uri = $request->get('uri') ?? throw new JsonRpcException( - 'Missing [uri] parameter.', - -32002, - $request->id, - ); - - $resource = $context->resources()->first( - fn (Resource $resource): bool => $resource->uri() === $uri, - $context->resourceTemplates()->first( - fn (Resource $template): bool => $template instanceof SupportsURITemplate && ! is_null($template->uriTemplate()->match($uri)), - ), - ); - - if (! $resource) { + 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 (Resource $template): bool => $template instanceof SupportsURITemplate && ! is_null($template->uriTemplate()->match($uri))); + + if (is_null($resource)) { throw new JsonRpcException("Resource [{$uri}] not found.", -32002, $request->id); } @@ -59,6 +62,10 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat : $this->toJsonRpcResponse($request, $response, $this->serializable($resource, $uri)); } + /** + * @throws BindingResolutionException + * @throws ValidationException + */ protected function invokeResource(Resource $resource, string $uri): mixed { $container = Container::getInstance(); diff --git a/src/Server/Resource.php b/src/Server/Resource.php index 5856cbd0..2ee56157 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -23,9 +23,7 @@ public function uri(): string return (string) $this->uriTemplate(); } - return $this->uri !== '' - ? $this->uri - : 'file://resources/'.Str::kebab(class_basename($this)); + return $this->uri !== '' ? $this->uri : 'file://resources/'.Str::kebab(class_basename($this)); } public function mimeType(): string @@ -62,7 +60,6 @@ public function toArray(): array 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), - 'uri' => $this->uri(), 'mimeType' => $this->mimeType(), ]; @@ -70,11 +67,9 @@ public function toArray(): array $data['annotations'] = $annotations; } - if ($this instanceof SupportsURITemplate) { - $data['uriTemplate'] = (string) $this->uriTemplate(); - } else { - $data['uri'] = $this->uri(); - } + ($this instanceof SupportsURITemplate) + ? $data['uriTemplate'] = (string) $this->uriTemplate() + : $data['uri'] = $this->uri(); // @phpstan-ignore return.type return $this->mergeMeta($data); diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 0966624c..117265c0 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -20,25 +20,29 @@ class UriTemplate implements Stringable private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*{[^{}]+}.*/'; - private string $template; + /** @var array */ + private static array $instances = []; /** @var list */ private array $variableNames = []; private ?string $compiledRegex = null; - public function __construct(string $template) + public function __construct(private readonly string $template) { - $this->validateLength($template, self::MAX_TEMPLATE_LENGTH, 'Template'); - if (! preg_match(self::URI_TEMPLATE_PATTERN, $template)) { throw new InvalidArgumentException('Invalid URI template: must be a valid URI template with at least one placeholder.'); } - $this->template = $template; + $this->validateLength($template, self::MAX_TEMPLATE_LENGTH, 'Template'); $this->variableNames = $this->extractVariableNames($template); } + public static function make(string $template): self + { + return self::$instances[$template] ??= new UriTemplate($template); + } + /** * @return array|null */ diff --git a/stubs/resource-template.stub b/stubs/resource-template.stub index e9170352..670ef615 100644 --- a/stubs/resource-template.stub +++ b/stubs/resource-template.stub @@ -22,7 +22,7 @@ class {{ class }} extends Resource implements SupportsURITemplate */ public function uriTemplate(): UriTemplate { - return new UriTemplate('file://resource/{id}'); + return UriTemplate::make('file://resource/{id}'); } /** diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php index 8320f250..bb047651 100644 --- a/tests/Unit/Support/UriTemplateTest.php +++ b/tests/Unit/Support/UriTemplateTest.php @@ -156,3 +156,33 @@ expect((string) $template)->toBe('file://users/{id}'); }); }); + +describe('UriTemplate::make', function (): void { + it('returns same instance for identical templates', function (): void { + $template1 = UriTemplate::make('file://resource/{id}'); + $template2 = UriTemplate::make('file://resource/{id}'); + + expect($template1)->toBe($template2); + }); + + it('returns different instances for different templates', function (): void { + $template1 = UriTemplate::make('file://resource/{id}'); + $template2 = UriTemplate::make('file://resource/{userId}'); + + expect($template1)->not->toBe($template2); + }); + + it('cached instances retain compiled regex', function (): void { + $template = UriTemplate::make('file://resource/{id}'); + + $result1 = $template->match('file://resource/123'); + + $cachedTemplate = UriTemplate::make('file://resource/{id}'); + + $result2 = $cachedTemplate->match('file://resource/456'); + + expect($result1)->toBe(['id' => '123']) + ->and($result2)->toBe(['id' => '456']) + ->and($template)->toBe($cachedTemplate); + }); +}); From 781f3e4135ce45428e89a2592f15702a5d030399 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 26 Nov 2025 18:56:11 +0530 Subject: [PATCH 21/33] Refactor Test --- src/Support/UriTemplate.php | 3 +- tests/Unit/Methods/CallToolTest.php | 17 +- .../Methods/ListResourceTemplatesTest.php | 1 - tests/Unit/Methods/ListResourcesTest.php | 77 ---- tests/Unit/Methods/ReadResourceTest.php | 414 ++++++------------ tests/Unit/Support/UriTemplateTest.php | 237 +++++----- 6 files changed, 243 insertions(+), 506 deletions(-) diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 117265c0..33e2bce1 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -30,11 +30,12 @@ class UriTemplate implements Stringable public function __construct(private readonly string $template) { + $this->validateLength($template, self::MAX_TEMPLATE_LENGTH, 'Template'); + if (! preg_match(self::URI_TEMPLATE_PATTERN, $template)) { throw new InvalidArgumentException('Invalid URI template: must be a valid URI template with at least one placeholder.'); } - $this->validateLength($template, self::MAX_TEMPLATE_LENGTH, 'Template'); $this->variableNames = $this->extractVariableNames($template); } diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index e40198fa..af8e7462 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -554,19 +554,13 @@ }); it('does not set uri on request when calling tools', function (): void { - $capturedUri = 'NOT_SET'; - - $tool = new class($capturedUri) extends Tool + $tool = new class extends Tool { - public function __construct(private &$uriRef) {} - protected string $description = 'Test tool'; public function handle(Request $request): Response { - $this->uriRef = $request->uri(); - - return Response::text('Test'); + return Response::text(json_encode($request->uri())); } public function schema(JsonSchema $schema): array @@ -604,7 +598,10 @@ public function schema(JsonSchema $schema): array $this->instance('mcp.request', $request->toRequest()); $method = new CallTool; - $method->handle($request, $context); + $payload = ($method->handle($request, $context))->toArray(); - expect($capturedUri)->toBeNull(); + expect($payload['id'])->toEqual(1) + ->and($payload['result']['content'])->toHaveCount(1) + ->and($payload['result']['content'][0]['type'])->toBe('text') + ->and($payload['result']['content'][0]['text'])->toBe('null'); }); diff --git a/tests/Unit/Methods/ListResourceTemplatesTest.php b/tests/Unit/Methods/ListResourceTemplatesTest.php index 86cb7bda..15699025 100644 --- a/tests/Unit/Methods/ListResourceTemplatesTest.php +++ b/tests/Unit/Methods/ListResourceTemplatesTest.php @@ -20,7 +20,6 @@ public function handle(): Response } }; - // Create a template resource $templateResource = new class extends Resource implements SupportsURITemplate { protected string $mimeType = 'text/plain'; diff --git a/tests/Unit/Methods/ListResourcesTest.php b/tests/Unit/Methods/ListResourcesTest.php index 18f5fd06..9ec4815e 100644 --- a/tests/Unit/Methods/ListResourcesTest.php +++ b/tests/Unit/Methods/ListResourcesTest.php @@ -324,80 +324,3 @@ public function handle(Request $request): Response ->and($payload['result']['resources'][0]['name'])->toBe($staticResource->name()) ->and($payload['result']['resources'][0]['uri'])->toBe($staticResource->uri()); }); - -it('returns empty list when only templates are registered', function (): void { - $template1 = new class extends Resource implements SupportsURITemplate - { - public function uriTemplate(): UriTemplate - { - return new UriTemplate('file://users/{userId}'); - } - - public function handle(Request $request): Response - { - return Response::text('user'); - } - }; - - $template2 = new class extends Resource implements SupportsURITemplate - { - public function uriTemplate(): UriTemplate - { - return new UriTemplate('file://posts/{postId}'); - } - - public function handle(Request $request): Response - { - return Response::text('post'); - } - }; - - $context = new ServerContext( - supportedProtocolVersions: ['2025-03-26'], - serverCapabilities: [], - serverName: 'Test Server', - serverVersion: '1.0.0', - instructions: 'Test instructions', - maxPaginationLength: 50, - defaultPaginationLength: 5, - tools: [], - resources: [$template1, $template2], - prompts: [], - ); - - $request = new JsonRpcRequest(id: 1, method: 'resources/list', params: []); - $listResources = new ListResources; - $response = $listResources->handle($request, $context); - - $payload = $response->toArray(); - - expect($payload['result'])->toEqual([ - 'resources' => [], - ]); -}); - -it('excludes template class strings from list', function (): void { - $staticResource = $this->makeResource(); - - $context = new ServerContext( - supportedProtocolVersions: ['2025-03-26'], - serverCapabilities: [], - serverName: 'Test Server', - serverVersion: '1.0.0', - instructions: 'Test instructions', - maxPaginationLength: 50, - defaultPaginationLength: 5, - tools: [], - resources: [Tests\Fixtures\ExampleResourceTemplate::class, $staticResource], - prompts: [], - ); - - $request = new JsonRpcRequest(id: 1, method: 'resources/list', params: []); - $listResources = new ListResources; - $response = $listResources->handle($request, $context); - - $payload = $response->toArray(); - - expect($payload['result']['resources'])->toHaveCount(1) - ->and($payload['result']['resources'][0]['name'])->toBe($staticResource->name()); -}); diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index 692324cb..aa897f12 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -34,6 +34,7 @@ ], ], $resourceResult); }); + it('returns a valid resource result for blob resources', function (): void { $resource = $this->makeBinaryResource(__DIR__.'/../../Fixtures/binary.png'); $readResource = new ReadResource; @@ -68,7 +69,6 @@ ); $response = $readResource->handle($jsonRpcRequest, $context); - }); it('throws exception when resource is not found', function (): void { @@ -86,12 +86,12 @@ $readResource->handle($jsonRpcRequest, $context); }); -it('reads resource template by matching URI pattern', function (): void { +it('reads resource template by matching a URI pattern', function (): void { $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}'); + return UriTemplate::make('file://users/{userId}'); } public function handle(Request $request): Response @@ -120,12 +120,12 @@ public function handle(Request $request): Response ], $result); }); -it('returns actual requested URI in response, not the template pattern', function (): void { +it('returns the actual requested URI in response, not the template pattern', function (): void { $template = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}'); + return UriTemplate::make('file://users/{userId}'); } public function handle(Request $request): Response @@ -149,114 +149,30 @@ public function handle(Request $request): Response $result = $readResource->handle($jsonRpcRequest, $context); $payload = $result->toArray(); - // The response URI should be the actual requested URI, not the template pattern expect($payload['result']['contents'][0]['uri'])->toBe($requestedUri) ->and($payload['result']['contents'][0]['uri'])->not->toBe('file://users/{userId}'); }); -it('extracts single variable from URI and passes to handler', function (): void { - $capturedUserId = null; - - $template = new class($capturedUserId) extends Resource implements SupportsURITemplate - { - public function __construct(private &$capturedValue) {} - - public function uriTemplate(): UriTemplate - { - return new UriTemplate('file://users/{userId}'); - } - - public function handle(Request $request): Response - { - $this->capturedValue = $request->get('userId'); - - return Response::text("User ID: {$this->capturedValue}"); - } - }; - - $context = $this->getServerContext([ - 'resources' => [$template], - ]); - - $jsonRpcRequest = new JsonRpcRequest( - id: 1, - method: 'resources/read', - params: ['uri' => 'file://users/42'] - ); - - $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); - - expect($capturedUserId)->toBe('42'); -}); - -it('extracts multiple variables from URI and passes to handler', function (): void { - $capturedVars = null; - - $template = new class($capturedVars) extends Resource implements SupportsURITemplate +it('extracts variables from URI template and passes to handler', function (string $templatePattern, string $uri, array $expected): void { + $resource = new class($templatePattern) extends Resource implements SupportsURITemplate { - public function __construct(private &$capturedValues) {} + public function __construct(private string $pattern) {} public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}/files/{fileId}'); + return UriTemplate::make($this->pattern); } public function handle(Request $request): Response { - $this->capturedValues = [ - 'userId' => $request->get('userId'), - 'fileId' => $request->get('fileId'), - ]; - - return Response::text('test'); + return Response::json($request->all()); } }; $context = $this->getServerContext([ - 'resources' => [$template], - ]); - - $jsonRpcRequest = new JsonRpcRequest( - id: 1, - method: 'resources/read', - params: ['uri' => 'file://users/100/files/document.pdf'] - ); - - $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); - - expect($capturedVars)->toBe([ - 'userId' => '100', - 'fileId' => 'document.pdf', - ]); -}); - -it('includes uri parameter along with extracted variables in request', function (): void { - $capturedAll = null; - - $template = new class($capturedAll) extends Resource implements SupportsURITemplate - { - public function __construct(private &$capturedData) {} - - public function uriTemplate(): UriTemplate - { - return new UriTemplate('file://users/{userId}'); - } - - public function handle(Request $request): Response - { - $this->capturedData = $request->all(); - - return Response::text('test'); - } - }; - - $context = $this->getServerContext([ - 'resources' => [$template], + 'resources' => [$resource], ]); - $uri = 'file://users/789'; $jsonRpcRequest = new JsonRpcRequest( id: 1, method: 'resources/read', @@ -264,38 +180,40 @@ public function handle(Request $request): Response ); $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); + $result = $readResource->handle($jsonRpcRequest, $context); + $payload = $result->toArray(); - expect($capturedAll)->toBe([ - 'userId' => '789', - ]); -}); + $responseData = json_decode((string) $payload['result']['contents'][0]['text'], true); + + expect($responseData)->toBe($expected); +})->with([ + 'single variable' => [ + 'templatePattern' => 'file://users/{userId}', + 'uri' => 'file://users/42', + 'expected' => ['userId' => '42'], + ], + 'multiple variables' => [ + 'templatePattern' => 'file://users/{userId}/files/{fileId}', + 'uri' => 'file://users/100/files/document.pdf', + 'expected' => ['userId' => '100', 'fileId' => 'document.pdf'], + ], +]); it('preserves sessionId and meta from the original request for template resources', function (): void { - $capturedSessionId = null; - $capturedMeta = null; - $capturedArguments = null; - - $template = new class($capturedSessionId, $capturedMeta, $capturedArguments) extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsURITemplate { - public function __construct( - private &$sessionIdRef, - private &$metaRef, - private &$argumentsRef - ) {} - public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}'); + return UriTemplate::make('file://users/{userId}'); } public function handle(Request $request): Response { - $this->sessionIdRef = $request->sessionId(); - $this->metaRef = $request->meta(); - $this->argumentsRef = $request->all(); - - return Response::text('test'); + return Response::json([ + 'sessionId' => $request->sessionId(), + 'meta' => $request->meta(), + 'arguments' => $request->all(), + ]); } }; @@ -321,37 +239,34 @@ public function handle(Request $request): Response try { $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); + $result = $readResource->handle($jsonRpcRequest, $context); + $payload = $result->toArray(); - expect($capturedSessionId)->toBe($sessionId) - ->and($capturedMeta)->toBe($meta) - ->and($capturedArguments)->toHaveKey('userId', '42') - ->and($capturedArguments)->toHaveKey('format', 'json'); + $responseData = json_decode((string) $payload['result']['contents'][0]['text'], true); + + expect($responseData['sessionId'])->toBe($sessionId) + ->and($responseData['meta'])->toBe($meta) + ->and($responseData['arguments'])->toHaveKey('userId', '42') + ->and($responseData['arguments'])->toHaveKey('format', 'json'); } finally { $container->forgetInstance('mcp.request'); } }); it('template handler receives variables via request get method', function (): void { - $accessMethodWorks = false; - - $template = new class($accessMethodWorks) extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsURITemplate { - public function __construct(private &$testResult) {} - public function uriTemplate(): UriTemplate { - return new UriTemplate('file://posts/{postId}/comments/{commentId}'); + return UriTemplate::make('file://posts/{postId}/comments/{commentId}'); } public function handle(Request $request): Response { - $postId = $request->get('postId'); - $commentId = $request->get('commentId'); - - $this->testResult = ($postId === '42' && $commentId === '7'); - - return Response::text('test'); + return Response::json([ + 'postId' => $request->get('postId'), + 'commentId' => $request->get('commentId'), + ]); } }; @@ -366,9 +281,13 @@ public function handle(Request $request): Response ); $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); + $result = $readResource->handle($jsonRpcRequest, $context); + $payload = $result->toArray(); + + $responseData = json_decode((string) $payload['result']['contents'][0]['text'], true); - expect($accessMethodWorks)->toBeTrue(); + expect($responseData['postId'])->toBe('42') + ->and($responseData['commentId'])->toBe('7'); }); it('tries static resources before template matching', function (): void { @@ -378,7 +297,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return new UriTemplate('file://resources/{resourceId}'); + return UriTemplate::make('file://resources/{resourceId}'); } public function handle(Request $request): Response @@ -407,12 +326,12 @@ public function handle(Request $request): Response ], $result); }); -it('returns first matching template when multiple templates exist', function (): void { +it('returns the first matching template when multiple templates exist', function (): void { $template1 = new class extends Resource implements SupportsURITemplate { public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}'); + return UriTemplate::make('file://users/{userId}'); } public function handle(Request $request): Response @@ -425,7 +344,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{id}'); + return UriTemplate::make('file://users/{id}'); } public function handle(Request $request): Response @@ -462,7 +381,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}'); + return UriTemplate::make('file://users/{userId}'); } public function handle(Request $request): Response @@ -535,27 +454,16 @@ public function handle(Request $request): Response }); it('does not leak variables between consecutive template resource requests', function (): void { - $firstRequestVars = null; - $secondRequestVars = null; - - $template = new class($firstRequestVars, $secondRequestVars) extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsURITemplate { - public function __construct(private &$firstRef, private &$secondRef) {} - public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}/posts/{postId}'); + return UriTemplate::make('file://users/{userId}/posts/{postId}'); } public function handle(Request $request): Response { - if ($this->firstRef === null) { - $this->firstRef = $request->all(); - } else { - $this->secondRef = $request->all(); - } - - return Response::text('test'); + return Response::json($request->all()); } }; @@ -570,14 +478,18 @@ public function handle(Request $request): Response method: 'resources/read', params: ['uri' => 'file://users/100/posts/42'] ); - $readResource->handle($firstJsonRpcRequest, $context); + $firstResult = $readResource->handle($firstJsonRpcRequest, $context); + $firstPayload = $firstResult->toArray(); + $firstRequestVars = json_decode((string) $firstPayload['result']['contents'][0]['text'], true); $secondJsonRpcRequest = new JsonRpcRequest( id: 2, method: 'resources/read', params: ['uri' => 'file://users/200/posts/99'] ); - $readResource->handle($secondJsonRpcRequest, $context); + $secondResult = $readResource->handle($secondJsonRpcRequest, $context); + $secondPayload = $secondResult->toArray(); + $secondRequestVars = json_decode((string) $secondPayload['result']['contents'][0]['text'], true); expect($firstRequestVars)->toBe([ 'userId' => '100', @@ -592,22 +504,16 @@ public function handle(Request $request): Response }); it('sets uri on request when reading resource templates', function (): void { - $capturedUri = null; - - $template = new class($capturedUri) extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsURITemplate { - public function __construct(private &$uriRef) {} - public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}'); + return UriTemplate::make('file://users/{userId}'); } public function handle(Request $request): Response { - $this->uriRef = $request->uri(); - - return Response::text('User data'); + return Response::json(['uri' => $request->uri()]); } }; @@ -623,69 +529,29 @@ public function handle(Request $request): Response ); $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); - - expect($capturedUri)->toBe($requestedUri); -}); - -it('uri contains the actual requested uri, not the template pattern', function (): void { - $capturedUri = null; - - $template = new class($capturedUri) extends Resource implements SupportsURITemplate - { - public function __construct(private &$uriRef) {} - - public function uriTemplate(): UriTemplate - { - return new UriTemplate('file://posts/{postId}/comments/{commentId}'); - } - - public function handle(Request $request): Response - { - $this->uriRef = $request->uri(); - - return Response::text('Comment'); - } - }; - - $context = $this->getServerContext([ - 'resources' => [$template], - ]); - - $requestedUri = 'file://posts/100/comments/5'; - $jsonRpcRequest = new JsonRpcRequest( - id: 1, - method: 'resources/read', - params: ['uri' => $requestedUri] - ); + $result = $readResource->handle($jsonRpcRequest, $context); + $payload = $result->toArray(); - $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); + $responseData = json_decode((string) $payload['result']['contents'][0]['text'], true); - expect($capturedUri)->toBe($requestedUri); + expect($responseData['uri'])->toBe($requestedUri); }); it('provides both uri and extracted variables in request for templates', function (): void { - $capturedData = null; - - $template = new class($capturedData) extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsURITemplate { - public function __construct(private &$dataRef) {} - public function uriTemplate(): UriTemplate { - return new UriTemplate('file://users/{userId}/files/{fileId}'); + return UriTemplate::make('file://users/{userId}/files/{fileId}'); } public function handle(Request $request): Response { - $this->dataRef = [ + return Response::json([ 'uri' => $request->uri(), 'userId' => $request->get('userId'), 'fileId' => $request->get('fileId'), - ]; - - return Response::text('File data'); + ]); } }; @@ -701,44 +567,48 @@ public function handle(Request $request): Response ); $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); + $result = $readResource->handle($jsonRpcRequest, $context); + $payload = $result->toArray(); - expect($capturedData)->toBe([ + $responseData = json_decode((string) $payload['result']['contents'][0]['text'], true); + + expect($responseData)->toBe([ 'uri' => 'file://users/123/files/document.pdf', 'userId' => '123', 'fileId' => 'document.pdf', ]); }); -it('uri is isolated between consecutive resource requests', function (): void { - $firstUri = null; - $secondUri = null; - $callCount = 0; - - $template = new class($firstUri, $secondUri, $callCount) extends Resource implements SupportsURITemplate - { - public function __construct(private &$firstRef, private &$secondRef, private &$count) {} - - public function uriTemplate(): UriTemplate +it('uri is correctly set and isolated for consecutive requests', function (string $resourceType, string $firstUri, string $secondUri): void { + if ($resourceType === 'template') { + $resource = new class extends Resource implements SupportsURITemplate { - return new UriTemplate('file://users/{userId}'); - } + public function uriTemplate(): UriTemplate + { + return UriTemplate::make('file://users/{userId}'); + } - public function handle(Request $request): Response - { - $this->count++; - if ($this->count === 1) { - $this->firstRef = $request->uri(); - } else { - $this->secondRef = $request->uri(); + public function handle(Request $request): Response + { + return Response::json(['uri' => $request->uri()]); } + }; + } else { + $resource = new class extends Resource + { + protected string $uri = 'file://static/resource'; - return Response::text('User data'); - } - }; + protected string $mimeType = 'text/plain'; + + public function handle(Request $request): Response + { + return Response::json(['uri' => $request->uri()]); + } + }; + } $context = $this->getServerContext([ - 'resources' => [$template], + 'resources' => [$resource], ]); $readResource = new ReadResource; @@ -746,52 +616,32 @@ public function handle(Request $request): Response $firstRequest = new JsonRpcRequest( id: 1, method: 'resources/read', - params: ['uri' => 'file://users/100'] + params: ['uri' => $firstUri] ); - $readResource->handle($firstRequest, $context); + $firstResult = $readResource->handle($firstRequest, $context); + $firstPayload = $firstResult->toArray(); + $firstResponseData = json_decode((string) $firstPayload['result']['contents'][0]['text'], true); $secondRequest = new JsonRpcRequest( id: 2, method: 'resources/read', - params: ['uri' => 'file://users/200'] + params: ['uri' => $secondUri] ); - $readResource->handle($secondRequest, $context); - - expect($firstUri)->toBe('file://users/100') - ->and($secondUri)->toBe('file://users/200'); -}); - -it('sets uri on request for static resources that accept request parameter', function (): void { - $capturedUri = null; - - $resource = new class($capturedUri) extends Resource - { - protected string $uri = 'file://static/resource'; - - protected string $mimeType = 'text/plain'; - - public function __construct(private &$uriRef) {} - - public function handle(Request $request): Response - { - $this->uriRef = $request->uri(); - - return Response::text('Static content'); - } - }; - - $context = $this->getServerContext([ - 'resources' => [$resource], - ]); - - $jsonRpcRequest = new JsonRpcRequest( - id: 1, - method: 'resources/read', - params: ['uri' => 'file://static/resource'] - ); - - $readResource = new ReadResource; - $readResource->handle($jsonRpcRequest, $context); - - expect($capturedUri)->toBe('file://static/resource'); -}); + $secondResult = $readResource->handle($secondRequest, $context); + $secondPayload = $secondResult->toArray(); + $secondResponseData = json_decode((string) $secondPayload['result']['contents'][0]['text'], true); + + expect($firstResponseData['uri'])->toBe($firstUri) + ->and($secondResponseData['uri'])->toBe($secondUri); +})->with([ + 'template resources' => [ + 'resourceType' => 'template', + 'firstUri' => 'file://users/100', + 'secondUri' => 'file://users/200', + ], + 'static resources' => [ + 'resourceType' => 'static', + 'firstUri' => 'file://static/resource', + 'secondUri' => 'file://static/resource', + ], +]); diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php index bb047651..b458e14d 100644 --- a/tests/Unit/Support/UriTemplateTest.php +++ b/tests/Unit/Support/UriTemplateTest.php @@ -2,187 +2,154 @@ use Laravel\Mcp\Support\UriTemplate; -describe('UriTemplate validation', function (): void { - it('requires a valid URI scheme', function (): void { - expect(fn (): UriTemplate => new UriTemplate('/invalid/no-scheme/{id}')) - ->toThrow(InvalidArgumentException::class, 'Invalid URI template: must be a valid URI template with at least one placeholder.'); - }); - - it('requires at least one placeholder', function (): void { - expect(fn (): UriTemplate => new UriTemplate('file://path/without/placeholder')) - ->toThrow(InvalidArgumentException::class, 'Invalid URI template: must be a valid URI template with at least one placeholder.'); - }); - - it('accepts valid URI templates', function (): void { - expect(new UriTemplate('file://users/{id}'))->toBeInstanceOf(UriTemplate::class) - ->and(new UriTemplate('https://api.example.com/{endpoint}'))->toBeInstanceOf(UriTemplate::class) - ->and(new UriTemplate('https://example.com/path/{var}'))->toBeInstanceOf(UriTemplate::class); - }); +it('requires a valid URI scheme', function (): void { + expect(fn (): UriTemplate => new UriTemplate('/invalid/no-scheme/{id}')) + ->toThrow(InvalidArgumentException::class, 'Invalid URI template: must be a valid URI template with at least one placeholder.'); }); -describe('UriTemplate::match', function (): void { - it('extracts variables from simple strings', function (): void { - $template = new UriTemplate('https://example.com/users/{username}'); - - expect($template->match('https://example.com/users/fred'))->toBe(['username' => 'fred']); - }); - - it('extracts multiple variables', function (): void { - $template = new UriTemplate('file://users/{username}/posts/{postId}'); +it('requires at least one placeholder', function (): void { + expect(fn (): UriTemplate => new UriTemplate('file://path/without/placeholder')) + ->toThrow(InvalidArgumentException::class, 'Invalid URI template: must be a valid URI template with at least one placeholder.'); +}); - expect($template->match('file://users/fred/posts/123'))->toBe(['username' => 'fred', 'postId' => '123']); - }); +it('accepts valid URI templates', function (): void { + expect(new UriTemplate('file://users/{id}'))->toBeInstanceOf(UriTemplate::class) + ->and(new UriTemplate('https://api.example.com/{endpoint}'))->toBeInstanceOf(UriTemplate::class) + ->and(new UriTemplate('https://example.com/path/{var}'))->toBeInstanceOf(UriTemplate::class); +}); - it('returns null for non-matching URIs', function (): void { - $template = new UriTemplate('file://users/{username}'); +it('extracts variables from simple strings', function (): void { + $template = new UriTemplate('https://example.com/users/{username}'); - expect($template->match('file://posts/123'))->toBeNull(); - }); + expect($template->match('https://example.com/users/fred'))->toBe(['username' => 'fred']); +}); - it('matches nested path segments', function (): void { - $template = new UriTemplate('https://api.example.com/{version}/{resource}/{id}'); +it('extracts multiple variables', function (): void { + $template = new UriTemplate('file://users/{username}/posts/{postId}'); - expect($template->match('https://api.example.com/v1/users/123'))->toBe([ - 'version' => 'v1', - 'resource' => 'users', - 'id' => '123', - ]); - }); + expect($template->match('file://users/fred/posts/123'))->toBe(['username' => 'fred', 'postId' => '123']); +}); - it('rejects partial matches', function (): void { - $template = new UriTemplate('file://users/{id}'); +it('returns null for non-matching URIs', function (): void { + $template = new UriTemplate('file://users/{username}'); - expect($template->match('file://users/123/extra'))->toBeNull() - ->and($template->match('file://users'))->toBeNull(); - }); + expect($template->match('file://posts/123'))->toBeNull(); }); -describe('UriTemplate simplified behavior', function (): void { - it('matches variables between slashes', function (): void { - $template = new UriTemplate('file://users/{userId}/posts/{postId}'); +it('matches nested path segments', function (): void { + $template = new UriTemplate('https://api.example.com/{version}/{resource}/{id}'); - expect($template->match('file://users/123/posts/456'))->toBe([ - 'userId' => '123', - 'postId' => '456', - ]) - ->and($template->match('file://users/123/posts'))->toBeNull() - ->and($template->match('file://users/123/posts/456/extra'))->toBeNull(); - }); + expect($template->match('https://api.example.com/v1/users/123'))->toBe([ + 'version' => 'v1', + 'resource' => 'users', + 'id' => '123', + ]); +}); - it('does not match variables across slashes', function (): void { - $template = new UriTemplate('file://files/{path}'); +it('rejects partial matches and incomplete URIs', function (): void { + $template = new UriTemplate('file://users/{id}'); - expect($template->match('file://files/foo/bar'))->toBeNull() - ->and($template->match('file://files/simple.txt'))->toBe(['path' => 'simple.txt']); - }); + expect($template->match('file://users/123/extra'))->toBeNull() + ->and($template->match('file://users'))->toBeNull(); }); -describe('UriTemplate edge cases', function (): void { - it('handles empty segments', function (): void { - $template = new UriTemplate('file://////{a}////{b}////'); +it('does not match variables across slashes', function (): void { + $template = new UriTemplate('file://files/{path}'); - expect($template->match('file://////1////2////'))->toBe(['a' => '1', 'b' => '2']); - }); + expect($template->match('file://files/foo/bar'))->toBeNull() + ->and($template->match('file://files/simple.txt'))->toBe(['path' => 'simple.txt']); }); -describe('UriTemplate security', function (): void { - it('handles extremely long input strings', function (): void { - $longString = str_repeat('x', 100000); - $template = new UriTemplate('https://api.example.com/{param}'); +it('handles empty segments', function (): void { + $template = new UriTemplate('file://////{a}////{b}////'); - expect($template->match('https://api.example.com/'.$longString))->toBe(['param' => $longString]); - }); + expect($template->match('file://////1////2////'))->toBe(['a' => '1', 'b' => '2']); +}); - it('throws when the template exceeds the maximum length', function (): void { - $longTemplate = str_repeat('x', 1000001); +it('handles extremely long input strings', function (): void { + $longString = str_repeat('x', 100000); + $template = new UriTemplate('https://api.example.com/{param}'); - expect(fn (): UriTemplate => new UriTemplate($longTemplate)) - ->toThrow(InvalidArgumentException::class, 'Template exceeds the maximum length'); - }); + expect($template->match('https://api.example.com/'.$longString))->toBe(['param' => $longString]); +}); - it('throws when URI exceeds maximum length', function (): void { - $template = new UriTemplate('https://api.example.com/{param}'); - $longUri = 'https://api.example.com/'.str_repeat('x', 1000001); +it('throws when the template exceeds the maximum length', function (): void { + $longTemplate = str_repeat('{x}', 1000001); - expect(fn (): ?array => $template->match($longUri)) - ->toThrow(InvalidArgumentException::class, 'URI exceeds the maximum length'); - }); + expect(fn (): UriTemplate => new UriTemplate($longTemplate)) + ->toThrow(InvalidArgumentException::class, 'Template exceeds the maximum length'); +}); - it('throws when the template has too many expressions', function (): void { - $tooManyExpressions = 'https://example.com/'.str_repeat('{a}', 10001); +it('throws when URI exceeds maximum length', function (): void { + $template = new UriTemplate('https://api.example.com/{param}'); + $longUri = 'https://api.example.com/'.str_repeat('x', 1000001); - expect(fn (): UriTemplate => new UriTemplate($tooManyExpressions)) - ->toThrow(InvalidArgumentException::class, 'Template contains too many expressions'); - }); + expect(fn (): ?array => $template->match($longUri)) + ->toThrow(InvalidArgumentException::class, 'URI exceeds the maximum length'); +}); - it('throws for unclosed template expressions', function (): void { - expect(fn (): UriTemplate => new UriTemplate('https://example.com/{unclosed')) - ->toThrow(InvalidArgumentException::class); - }); +it('throws when the template has too many expressions', function (): void { + $tooManyExpressions = 'https://example.com/'.str_repeat('{a}', 10001); - it('handles pathological regex patterns', function (): void { - $template = new UriTemplate('https://api.example.com/{param}'); - $input = 'https://api.example.com/'.str_repeat('a', 100000); + expect(fn (): UriTemplate => new UriTemplate($tooManyExpressions)) + ->toThrow(InvalidArgumentException::class, 'Template contains too many expressions'); +}); - expect(fn (): ?array => $template->match($input))->not->toThrow(Exception::class); - }); +it('throws for unclosed template expressions', function (): void { + expect(fn (): UriTemplate => new UriTemplate('https://example.com/{unclosed')) + ->toThrow(InvalidArgumentException::class); +}); - it('handles invalid UTF-8 sequences', function (): void { - $template = new UriTemplate('https://api.example.com/{param}'); - $invalidUtf8 = '���'; +it('handles pathological regex patterns', function (): void { + $template = new UriTemplate('https://api.example.com/{param}'); + $input = 'https://api.example.com/'.str_repeat('a', 100000); - expect(fn (): ?array => $template->match('https://api.example.com/'.$invalidUtf8))->not->toThrow(Exception::class); - }); + expect(fn (): ?array => $template->match($input))->not->toThrow(Exception::class); +}); - it('handles template/URI length mismatches', function (): void { - $template = new UriTemplate('https://api.example.com/{param}'); +it('handles invalid UTF-8 sequences', function (): void { + $template = new UriTemplate('https://api.example.com/{param}'); + $invalidUtf8 = '���'; - expect($template->match('https://api.example.com/'))->toBeNull() - ->and($template->match('https://api.example.com'))->toBeNull() - ->and($template->match('https://api.example.com/value/extra'))->toBeNull(); - }); + expect(fn (): ?array => $template->match('https://api.example.com/'.$invalidUtf8))->not->toThrow(Exception::class); +}); - it('handles maximum template expression limit', function (): void { - $expressions = 'https://example.com/'.str_repeat('{param}', 10000); +it('handles maximum template expression limit', function (): void { + $expressions = 'https://example.com/'.str_repeat('{param}', 10000); - expect(fn (): UriTemplate => new UriTemplate($expressions))->not->toThrow(Exception::class); - }); + expect(fn (): UriTemplate => new UriTemplate($expressions))->not->toThrow(Exception::class); }); -describe('UriTemplate::__toString', function (): void { - it('casts to string', function (): void { - $template = new UriTemplate('file://users/{id}'); +it('casts to string', function (): void { + $template = new UriTemplate('file://users/{id}'); - expect((string) $template)->toBe('file://users/{id}'); - }); + expect((string) $template)->toBe('file://users/{id}'); }); -describe('UriTemplate::make', function (): void { - it('returns same instance for identical templates', function (): void { - $template1 = UriTemplate::make('file://resource/{id}'); - $template2 = UriTemplate::make('file://resource/{id}'); +it('returns same instance for identical templates', function (): void { + $template1 = UriTemplate::make('file://resource/{id}'); + $template2 = UriTemplate::make('file://resource/{id}'); - expect($template1)->toBe($template2); - }); + expect($template1)->toBe($template2); +}); - it('returns different instances for different templates', function (): void { - $template1 = UriTemplate::make('file://resource/{id}'); - $template2 = UriTemplate::make('file://resource/{userId}'); +it('returns different instances for different templates', function (): void { + $template1 = UriTemplate::make('file://resource/{id}'); + $template2 = UriTemplate::make('file://resource/{userId}'); - expect($template1)->not->toBe($template2); - }); + expect($template1)->not->toBe($template2); +}); - it('cached instances retain compiled regex', function (): void { - $template = UriTemplate::make('file://resource/{id}'); +it('cached instances retain compiled regex', function (): void { + $template = UriTemplate::make('file://resource/{id}'); - $result1 = $template->match('file://resource/123'); + $result1 = $template->match('file://resource/123'); - $cachedTemplate = UriTemplate::make('file://resource/{id}'); + $cachedTemplate = UriTemplate::make('file://resource/{id}'); - $result2 = $cachedTemplate->match('file://resource/456'); + $result2 = $cachedTemplate->match('file://resource/456'); - expect($result1)->toBe(['id' => '123']) - ->and($result2)->toBe(['id' => '456']) - ->and($template)->toBe($cachedTemplate); - }); + expect($result1)->toBe(['id' => '123']) + ->and($result2)->toBe(['id' => '456']) + ->and($template)->toBe($cachedTemplate); }); From bc663739c0cd8c2ce7377787d20a7c0e14063319 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 26 Nov 2025 19:31:09 +0530 Subject: [PATCH 22/33] Refactor --- src/Request.php | 4 ++-- src/Server/Methods/ReadResource.php | 2 +- src/Server/Resource.php | 2 +- src/Server/ServerContext.php | 2 +- tests/Unit/RequestTest.php | 12 +----------- 5 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/Request.php b/src/Request.php index 2b1d04e4..d663e663 100644 --- a/src/Request.php +++ b/src/Request.php @@ -139,8 +139,8 @@ public function setMeta(?array $meta): void $this->meta = $meta; } - public function setUri(?string $uri): ?string + public function setUri(?string $uri): void { - return $this->uri = $uri; + $this->uri = $uri; } } diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index bfebd145..3c3bea04 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -45,7 +45,7 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat /** @var Resource|null $resource */ $resource = $context->resources()->first(fn (Resource $resource): bool => $resource->uri() === $uri) ?? - $context->resourceTemplates()->first(fn (Resource $template): bool => $template instanceof SupportsURITemplate && ! is_null($template->uriTemplate()->match($uri))); + $context->resourceTemplates()->first(fn (SupportsURITemplate $template): bool => ! is_null($template->uriTemplate()->match($uri))); if (is_null($resource)) { throw new JsonRpcException("Resource [{$uri}] not found.", -32002, $request->id); diff --git a/src/Server/Resource.php b/src/Server/Resource.php index 2ee56157..f1607211 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -47,7 +47,7 @@ public function toMethodCall(): array * title: string, * description: string, * uri?: string, - * uriTemplate? :string, + * uriTemplate?: string, * mimeType: string, * _meta?: array * } diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 495a9059..123e4b16 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -57,7 +57,7 @@ public function resources(): Collection } /** - * @return Collection + * @return Collection */ public function resourceTemplates(): Collection { diff --git a/tests/Unit/RequestTest.php b/tests/Unit/RequestTest.php index 35f070b1..f22fb8d5 100644 --- a/tests/Unit/RequestTest.php +++ b/tests/Unit/RequestTest.php @@ -150,8 +150,7 @@ $result = $request->setUri('file://resources/test'); - expect($request->uri())->toBe('file://resources/test') - ->and($result)->toBe('file://resources/test'); + expect($request->uri())->toBe('file://resources/test'); }); it('can update uri using setUri method', function (): void { @@ -177,15 +176,6 @@ ->and($result)->toBeNull(); }); -it('setUri returns the set value for method chaining', function (): void { - $request = new Request(['name' => 'Alice']); - - $returnedUri = $request->setUri('file://resources/test'); - - expect($returnedUri)->toBe('file://resources/test') - ->and($returnedUri)->toBe($request->uri()); -}); - it('supports method chaining with merge and setUri', function (): void { $request = new Request(['name' => 'Alice']); From 00708572a7e2d5b6ce00251b4babd6dc24b014c6 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 26 Nov 2025 22:46:36 +0530 Subject: [PATCH 23/33] Refactor --- ...RITemplate.php => SupportsUriTemplate.php} | 2 +- src/Server/Methods/ReadResource.php | 6 ++-- src/Server/Resource.php | 12 ++++---- src/Server/ServerContext.php | 8 +++--- src/Support/UriTemplate.php | 4 +-- stubs/resource-template.stub | 4 +-- tests/Fixtures/ExampleResourceTemplate.php | 4 +-- .../Methods/ListResourceTemplatesTest.php | 6 ++-- tests/Unit/Methods/ListResourcesTest.php | 6 ++-- tests/Unit/Methods/ReadResourceTest.php | 28 +++++++++---------- tests/Unit/Resources/ResourceTemplateTest.php | 22 +++++++-------- 11 files changed, 51 insertions(+), 51 deletions(-) rename src/Server/Contracts/{SupportsURITemplate.php => SupportsUriTemplate.php} (84%) diff --git a/src/Server/Contracts/SupportsURITemplate.php b/src/Server/Contracts/SupportsUriTemplate.php similarity index 84% rename from src/Server/Contracts/SupportsURITemplate.php rename to src/Server/Contracts/SupportsUriTemplate.php index 0d592d05..448b1465 100644 --- a/src/Server/Contracts/SupportsURITemplate.php +++ b/src/Server/Contracts/SupportsUriTemplate.php @@ -6,7 +6,7 @@ use Laravel\Mcp\Support\UriTemplate; -interface SupportsURITemplate +interface SupportsUriTemplate { public function uriTemplate(): UriTemplate; } diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index 3c3bea04..e0a79531 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -12,7 +12,7 @@ use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Contracts\Method; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses; use Laravel\Mcp\Server\Resource; @@ -45,7 +45,7 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat /** @var Resource|null $resource */ $resource = $context->resources()->first(fn (Resource $resource): bool => $resource->uri() === $uri) ?? - $context->resourceTemplates()->first(fn (SupportsURITemplate $template): bool => ! is_null($template->uriTemplate()->match($uri))); + $context->resourceTemplates()->first(fn (SupportsUriTemplate $template): bool => ! is_null($template->uriTemplate()->match($uri))); if (is_null($resource)) { throw new JsonRpcException("Resource [{$uri}] not found.", -32002, $request->id); @@ -73,7 +73,7 @@ protected function invokeResource(Resource $resource, string $uri): mixed $request = $container->make(Request::class); $request->setUri($uri); - if ($resource instanceof SupportsURITemplate) { + if ($resource instanceof SupportsUriTemplate) { $variables = $resource->uriTemplate()->match($uri) ?? []; $request->merge($variables); } diff --git a/src/Server/Resource.php b/src/Server/Resource.php index f1607211..81daefe3 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -7,7 +7,7 @@ use Illuminate\Support\Str; use Laravel\Mcp\Server\Annotations\Annotation; use Laravel\Mcp\Server\Concerns\HasAnnotations; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; abstract class Resource extends Primitive { @@ -19,7 +19,7 @@ abstract class Resource extends Primitive public function uri(): string { - if ($this instanceof SupportsURITemplate) { + if ($this instanceof SupportsUriTemplate) { return (string) $this->uriTemplate(); } @@ -67,9 +67,11 @@ public function toArray(): array $data['annotations'] = $annotations; } - ($this instanceof SupportsURITemplate) - ? $data['uriTemplate'] = (string) $this->uriTemplate() - : $data['uri'] = $this->uri(); + if ($this instanceof SupportsUriTemplate) { + $data['uriTemplate'] = $this->uriTemplate(); + } else { + $data['uri'] = $this->uri(); + } // @phpstan-ignore return.type return $this->mergeMeta($data); diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 123e4b16..3d1f3e4e 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -6,7 +6,7 @@ use Illuminate\Container\Container; use Illuminate\Support\Collection; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; class ServerContext { @@ -49,7 +49,7 @@ public function tools(): Collection public function resources(): Collection { return collect($this->resources) - ->filter(fn ($resource): bool => ! ($resource instanceof SupportsURITemplate) && ! (is_string($resource) && is_subclass_of($resource, SupportsURITemplate::class))) + ->filter(fn ($resource): bool => ! ($resource instanceof SupportsUriTemplate) && ! (is_string($resource) && is_subclass_of($resource, SupportsUriTemplate::class))) ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) ? Container::getInstance()->make($resourceClass) : $resourceClass) @@ -57,12 +57,12 @@ public function resources(): Collection } /** - * @return Collection + * @return Collection */ public function resourceTemplates(): Collection { return collect($this->resources) - ->filter(fn ($resource): bool => ($resource instanceof SupportsURITemplate) || (is_string($resource) && is_subclass_of($resource, SupportsURITemplate::class))) + ->filter(fn ($resource): bool => ($resource instanceof SupportsUriTemplate) || (is_string($resource) && is_subclass_of($resource, SupportsUriTemplate::class))) ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) ? Container::getInstance()->make($resourceClass) : $resourceClass) diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 33e2bce1..01151f15 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -51,9 +51,7 @@ public function match(string $uri): ?array { $this->validateLength($uri, self::MAX_TEMPLATE_LENGTH, 'URI'); - if (is_null($this->compiledRegex)) { - $this->compiledRegex = $this->compileRegex(); - } + $this->compiledRegex ??= $this->compileRegex(); if (! preg_match($this->compiledRegex, $uri, $matches)) { return null; diff --git a/stubs/resource-template.stub b/stubs/resource-template.stub index 670ef615..bdb7a0b8 100644 --- a/stubs/resource-template.stub +++ b/stubs/resource-template.stub @@ -4,11 +4,11 @@ namespace {{ namespace }}; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportUriTemplate; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; -class {{ class }} extends Resource implements SupportsURITemplate +class {{ class }} extends Resource implements SupportUriTemplate { /** * The resource's description. diff --git a/tests/Fixtures/ExampleResourceTemplate.php b/tests/Fixtures/ExampleResourceTemplate.php index 326272f6..695130dc 100644 --- a/tests/Fixtures/ExampleResourceTemplate.php +++ b/tests/Fixtures/ExampleResourceTemplate.php @@ -6,11 +6,11 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; -class ExampleResourceTemplate extends Resource implements SupportsURITemplate +class ExampleResourceTemplate extends Resource implements SupportsUriTemplate { protected string $description = 'Example resource template for testing'; diff --git a/tests/Unit/Methods/ListResourceTemplatesTest.php b/tests/Unit/Methods/ListResourceTemplatesTest.php index 15699025..ec09810a 100644 --- a/tests/Unit/Methods/ListResourceTemplatesTest.php +++ b/tests/Unit/Methods/ListResourceTemplatesTest.php @@ -2,7 +2,7 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; use Laravel\Mcp\Server\Methods\ListResourceTemplates; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\ServerContext; @@ -20,7 +20,7 @@ public function handle(): Response } }; - $templateResource = new class extends Resource implements SupportsURITemplate + $templateResource = new class extends Resource implements SupportsUriTemplate { protected string $mimeType = 'text/plain'; @@ -108,7 +108,7 @@ public function handle(): Response }); it('includes template metadata in the listing', function (): void { - $templateResource = new class extends Resource implements SupportsURITemplate + $templateResource = new class extends Resource implements SupportsUriTemplate { protected string $name = 'user-file'; diff --git a/tests/Unit/Methods/ListResourcesTest.php b/tests/Unit/Methods/ListResourcesTest.php index 9ec4815e..798688f4 100644 --- a/tests/Unit/Methods/ListResourcesTest.php +++ b/tests/Unit/Methods/ListResourcesTest.php @@ -8,7 +8,7 @@ use Laravel\Mcp\Server\Annotations\Audience; use Laravel\Mcp\Server\Annotations\LastModified; use Laravel\Mcp\Server\Annotations\Priority; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; use Laravel\Mcp\Server\Methods\ListResources; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\ServerContext; @@ -248,7 +248,7 @@ public function handle(): string }); it('excludes resource templates from list', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -288,7 +288,7 @@ public function handle(Request $request): Response it('returns only static resources when both templates and static resources exist', function (): void { $staticResource = $this->makeResource(); - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index aa897f12..75cc8215 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -5,7 +5,7 @@ use Illuminate\Container\Container; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\ReadResource; use Laravel\Mcp\Server\Resource; @@ -87,7 +87,7 @@ }); it('reads resource template by matching a URI pattern', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -121,7 +121,7 @@ public function handle(Request $request): Response }); it('returns the actual requested URI in response, not the template pattern', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -154,7 +154,7 @@ public function handle(Request $request): Response }); it('extracts variables from URI template and passes to handler', function (string $templatePattern, string $uri, array $expected): void { - $resource = new class($templatePattern) extends Resource implements SupportsURITemplate + $resource = new class($templatePattern) extends Resource implements SupportsUriTemplate { public function __construct(private string $pattern) {} @@ -200,7 +200,7 @@ public function handle(Request $request): Response ]); it('preserves sessionId and meta from the original request for template resources', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -254,7 +254,7 @@ public function handle(Request $request): Response }); it('template handler receives variables via request get method', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -293,7 +293,7 @@ public function handle(Request $request): Response it('tries static resources before template matching', function (): void { $staticResource = $this->makeResource('Static resource content'); - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -327,7 +327,7 @@ public function handle(Request $request): Response }); it('returns the first matching template when multiple templates exist', function (): void { - $template1 = new class extends Resource implements SupportsURITemplate + $template1 = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -340,7 +340,7 @@ public function handle(Request $request): Response } }; - $template2 = new class extends Resource implements SupportsURITemplate + $template2 = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -377,7 +377,7 @@ public function handle(Request $request): Response $this->expectException(JsonRpcException::class); $this->expectExceptionMessage('Resource [file://posts/123] not found.'); - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -454,7 +454,7 @@ public function handle(Request $request): Response }); it('does not leak variables between consecutive template resource requests', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -504,7 +504,7 @@ public function handle(Request $request): Response }); it('sets uri on request when reading resource templates', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -538,7 +538,7 @@ public function handle(Request $request): Response }); it('provides both uri and extracted variables in request for templates', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -581,7 +581,7 @@ public function handle(Request $request): Response it('uri is correctly set and isolated for consecutive requests', function (string $resourceType, string $firstUri, string $secondUri): void { if ($resourceType === 'template') { - $resource = new class extends Resource implements SupportsURITemplate + $resource = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { diff --git a/tests/Unit/Resources/ResourceTemplateTest.php b/tests/Unit/Resources/ResourceTemplateTest.php index c412b606..d3322fda 100644 --- a/tests/Unit/Resources/ResourceTemplateTest.php +++ b/tests/Unit/Resources/ResourceTemplateTest.php @@ -2,12 +2,12 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsURITemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; it('compiles URI template and extracts variable names', function (): void { - $resource = new class extends Resource implements SupportsURITemplate + $resource = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -21,11 +21,11 @@ public function handle(Request $request): Response }; expect($resource->uri())->toBe('file://users/{userId}/files/{fileId}') - ->and($resource)->toBeInstanceOf(SupportsURITemplate::class); + ->and($resource)->toBeInstanceOf(SupportsUriTemplate::class); }); it('matches URIs against a template pattern', function (): void { - $resource = new class extends Resource implements SupportsURITemplate + $resource = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -46,7 +46,7 @@ public function handle(Request $request): Response }); it('extracts variables from matching URI', function (): void { - $resource = new class extends Resource implements SupportsURITemplate + $resource = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -69,7 +69,7 @@ public function handle(Request $request): Response }); it('handles template resource with extracted variables', function (): void { - $resource = new class extends Resource implements SupportsURITemplate + $resource = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -96,7 +96,7 @@ public function handle(Request $request): Response }); it('handles template with single variable', function (): void { - $resource = new class extends Resource implements SupportsURITemplate + $resource = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -114,7 +114,7 @@ public function handle(Request $request): Response }); it('handles complex URI templates with multiple path segments', function (): void { - $resource = new class extends Resource implements SupportsURITemplate + $resource = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -138,7 +138,7 @@ public function handle(Request $request): Response }); it('does not match URIs with different path structure', function (): void { - $resource = new class extends Resource implements SupportsURITemplate + $resource = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { @@ -167,11 +167,11 @@ public function handle(): Response } }; - expect($resource)->not->toBeInstanceOf(SupportsURITemplate::class); + expect($resource)->not->toBeInstanceOf(SupportsUriTemplate::class); }); it('end to end template reads uri extracts variables and returns response', function (): void { - $template = new class extends Resource implements SupportsURITemplate + $template = new class extends Resource implements SupportsUriTemplate { public function uriTemplate(): UriTemplate { From bed6009276681b3c32aac53eed466bc6f70c5c3e Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 26 Nov 2025 23:51:55 +0530 Subject: [PATCH 24/33] Fix Test --- src/Server/Resource.php | 2 +- src/Server/ServerContext.php | 61 ++++++++++++++++--------- src/Support/UriTemplate.php | 8 ---- stubs/resource-template.stub | 2 +- tests/Unit/Methods/ReadResourceTest.php | 26 +++++------ tests/Unit/Support/UriTemplateTest.php | 28 ------------ 6 files changed, 54 insertions(+), 73 deletions(-) diff --git a/src/Server/Resource.php b/src/Server/Resource.php index 81daefe3..2e7fb32c 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -68,7 +68,7 @@ public function toArray(): array } if ($this instanceof SupportsUriTemplate) { - $data['uriTemplate'] = $this->uriTemplate(); + $data['uriTemplate'] = (string) $this->uriTemplate(); } else { $data['uri'] = $this->uri(); } diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 3d1f3e4e..2fce4441 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -37,10 +37,10 @@ public function __construct( */ public function tools(): Collection { - return collect($this->tools)->map(fn (Tool|string $toolClass) => is_string($toolClass) - ? Container::getInstance()->make($toolClass) - : $toolClass - )->filter(fn (Tool $tool): bool => $tool->eligibleForRegistration()); + /** @var Collection $tool */ + $tool = collect($this->tools); + + return $this->resolvePrimitives($tool); } /** @@ -48,25 +48,23 @@ public function tools(): Collection */ public function resources(): Collection { - return collect($this->resources) - ->filter(fn ($resource): bool => ! ($resource instanceof SupportsUriTemplate) && ! (is_string($resource) && is_subclass_of($resource, SupportsUriTemplate::class))) - ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) - ? Container::getInstance()->make($resourceClass) - : $resourceClass) - ->filter(fn (Resource $resource): bool => $resource->eligibleForRegistration()); + /** @var Collection $resourceTemplates */ + $resourceTemplates = collect($this->resources) + ->filter(fn (Resource|string $resource): bool => ! $this->isResourceTemplate($resource)); + + return $this->resolvePrimitives($resourceTemplates); } /** - * @return Collection + * @return Collection */ public function resourceTemplates(): Collection { - return collect($this->resources) - ->filter(fn ($resource): bool => ($resource instanceof SupportsUriTemplate) || (is_string($resource) && is_subclass_of($resource, SupportsUriTemplate::class))) - ->map(fn (Resource|string $resourceClass) => is_string($resourceClass) - ? Container::getInstance()->make($resourceClass) - : $resourceClass) - ->filter(fn (Resource $resource): bool => $resource->eligibleForRegistration()); + /** @var Collection $resourceTemplates */ + $resourceTemplates = collect($this->resources) + ->filter(fn (Resource|string $resource): bool => $this->isResourceTemplate($resource)); + + return $this->resolvePrimitives($resourceTemplates); } /** @@ -74,15 +72,34 @@ public function resourceTemplates(): Collection */ public function prompts(): Collection { - return collect($this->prompts)->map( - fn ($promptClass) => is_string($promptClass) - ? Container::getInstance()->make($promptClass) - : $promptClass - )->filter(fn (Prompt $prompt): bool => $prompt->eligibleForRegistration()); + /** @var Collection $prompts */ + $prompts = collect($this->prompts); + + return $this->resolvePrimitives($prompts); } public function perPage(?int $requestedPerPage = null): int { return min($requestedPerPage ?? $this->defaultPaginationLength, $this->maxPaginationLength); } + + /** + * @template T of Primitive + * + * @param Collection $primitive + * @return Collection + */ + private function resolvePrimitives(Collection $primitive): Collection + { + return $primitive->map(fn (Primitive|string $primitiveClass) => is_string($primitiveClass) + ? Container::getInstance()->make($primitiveClass) + : $primitiveClass) + ->filter(fn (Primitive $primitive): bool => $primitive->eligibleForRegistration()); + } + + private function isResourceTemplate(Resource|string $resource): bool + { + return $resource instanceof SupportsUriTemplate + || (is_string($resource) && is_subclass_of($resource, SupportsUriTemplate::class)); + } } diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 01151f15..a5ecac64 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -20,9 +20,6 @@ class UriTemplate implements Stringable private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*{[^{}]+}.*/'; - /** @var array */ - private static array $instances = []; - /** @var list */ private array $variableNames = []; @@ -39,11 +36,6 @@ public function __construct(private readonly string $template) $this->variableNames = $this->extractVariableNames($template); } - public static function make(string $template): self - { - return self::$instances[$template] ??= new UriTemplate($template); - } - /** * @return array|null */ diff --git a/stubs/resource-template.stub b/stubs/resource-template.stub index bdb7a0b8..20d16c36 100644 --- a/stubs/resource-template.stub +++ b/stubs/resource-template.stub @@ -22,7 +22,7 @@ class {{ class }} extends Resource implements SupportUriTemplate */ public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://resource/{id}'); + return new UriTemplate('file://resource/{id}'); } /** diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index 75cc8215..c4bfd55a 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -91,7 +91,7 @@ { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}'); + return new UriTemplate('file://users/{userId}'); } public function handle(Request $request): Response @@ -125,7 +125,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}'); + return new UriTemplate('file://users/{userId}'); } public function handle(Request $request): Response @@ -160,7 +160,7 @@ public function __construct(private string $pattern) {} public function uriTemplate(): UriTemplate { - return UriTemplate::make($this->pattern); + return new UriTemplate($this->pattern); } public function handle(Request $request): Response @@ -204,7 +204,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}'); + return new UriTemplate('file://users/{userId}'); } public function handle(Request $request): Response @@ -258,7 +258,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://posts/{postId}/comments/{commentId}'); + return new UriTemplate('file://posts/{postId}/comments/{commentId}'); } public function handle(Request $request): Response @@ -297,7 +297,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://resources/{resourceId}'); + return new UriTemplate('file://resources/{resourceId}'); } public function handle(Request $request): Response @@ -331,7 +331,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}'); + return new UriTemplate('file://users/{userId}'); } public function handle(Request $request): Response @@ -344,7 +344,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{id}'); + return new UriTemplate('file://users/{id}'); } public function handle(Request $request): Response @@ -381,7 +381,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}'); + return new UriTemplate('file://users/{userId}'); } public function handle(Request $request): Response @@ -458,7 +458,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}/posts/{postId}'); + return new UriTemplate('file://users/{userId}/posts/{postId}'); } public function handle(Request $request): Response @@ -508,7 +508,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}'); + return new UriTemplate('file://users/{userId}'); } public function handle(Request $request): Response @@ -542,7 +542,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}/files/{fileId}'); + return new UriTemplate('file://users/{userId}/files/{fileId}'); } public function handle(Request $request): Response @@ -585,7 +585,7 @@ public function handle(Request $request): Response { public function uriTemplate(): UriTemplate { - return UriTemplate::make('file://users/{userId}'); + return new UriTemplate('file://users/{userId}'); } public function handle(Request $request): Response diff --git a/tests/Unit/Support/UriTemplateTest.php b/tests/Unit/Support/UriTemplateTest.php index b458e14d..b6d8ce8b 100644 --- a/tests/Unit/Support/UriTemplateTest.php +++ b/tests/Unit/Support/UriTemplateTest.php @@ -125,31 +125,3 @@ expect((string) $template)->toBe('file://users/{id}'); }); - -it('returns same instance for identical templates', function (): void { - $template1 = UriTemplate::make('file://resource/{id}'); - $template2 = UriTemplate::make('file://resource/{id}'); - - expect($template1)->toBe($template2); -}); - -it('returns different instances for different templates', function (): void { - $template1 = UriTemplate::make('file://resource/{id}'); - $template2 = UriTemplate::make('file://resource/{userId}'); - - expect($template1)->not->toBe($template2); -}); - -it('cached instances retain compiled regex', function (): void { - $template = UriTemplate::make('file://resource/{id}'); - - $result1 = $template->match('file://resource/123'); - - $cachedTemplate = UriTemplate::make('file://resource/{id}'); - - $result2 = $cachedTemplate->match('file://resource/456'); - - expect($result1)->toBe(['id' => '123']) - ->and($result2)->toBe(['id' => '456']) - ->and($template)->toBe($cachedTemplate); -}); From 2fa4692ea8a7697d03bafdabcd71b2befc342774 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 26 Nov 2025 23:59:16 +0530 Subject: [PATCH 25/33] Refactor tools variable name --- src/Server/ServerContext.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 2fce4441..799b3db7 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -37,10 +37,10 @@ public function __construct( */ public function tools(): Collection { - /** @var Collection $tool */ - $tool = collect($this->tools); + /** @var Collection $tools */ + $tools = collect($this->tools); - return $this->resolvePrimitives($tool); + return $this->resolvePrimitives($tools); } /** From bf35228caa6c0532d171c20b30c92f11f36b9b9f Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 27 Nov 2025 00:08:31 +0530 Subject: [PATCH 26/33] Formatting --- src/Server/ServerContext.php | 3 +-- src/Support/UriTemplate.php | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 799b3db7..432caae1 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -99,7 +99,6 @@ private function resolvePrimitives(Collection $primitive): Collection private function isResourceTemplate(Resource|string $resource): bool { - return $resource instanceof SupportsUriTemplate - || (is_string($resource) && is_subclass_of($resource, SupportsUriTemplate::class)); + return $resource instanceof SupportsUriTemplate || (is_string($resource) && is_subclass_of($resource, SupportsUriTemplate::class)); } } diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index a5ecac64..647ec2e7 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -50,6 +50,7 @@ public function match(string $uri): ?array } $result = []; + foreach ($this->variableNames as $i => $name) { $result[$name] = $matches[$i + 1] ?? ''; } @@ -67,7 +68,7 @@ private function validateLength(string $str, int $max, string $context): void throw_if( Str::length($str) > $max, InvalidArgumentException::class, - sprintf('%s exceeds the maximum length of %d characters (got %d)', $context, $max, Str::length($str)) + sprintf('%s exceeds the maximum length of %d characters (received %d)', $context, $max, Str::length($str)) ); } From f7d747cee3d7582770b8c9e2fda449d450638ec9 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 27 Nov 2025 00:28:42 +0530 Subject: [PATCH 27/33] SupportUriTemplate -> SupportsUriTemplate --- stubs/resource-template.stub | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stubs/resource-template.stub b/stubs/resource-template.stub index 20d16c36..a3caa8ee 100644 --- a/stubs/resource-template.stub +++ b/stubs/resource-template.stub @@ -4,11 +4,11 @@ namespace {{ namespace }}; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportUriTemplate; +use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; -class {{ class }} extends Resource implements SupportUriTemplate +class {{ class }} extends Resource implements SupportsUriTemplate { /** * The resource's description. @@ -18,7 +18,7 @@ class {{ class }} extends Resource implements SupportUriTemplate MARKDOWN; /** - * Get the URI template pattern. + * Define the URI template pattern. */ public function uriTemplate(): UriTemplate { From 9c32483ee38ee880f3900dc5bb4ad6822b882da3 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 27 Nov 2025 00:35:32 +0530 Subject: [PATCH 28/33] improve numeric readability --- src/Support/UriTemplate.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Support/UriTemplate.php b/src/Support/UriTemplate.php index 647ec2e7..ff9d14a2 100644 --- a/src/Support/UriTemplate.php +++ b/src/Support/UriTemplate.php @@ -10,13 +10,13 @@ class UriTemplate implements Stringable { - private const MAX_TEMPLATE_LENGTH = 1000000; + private const MAX_TEMPLATE_LENGTH = 1_000_000; - private const MAX_VARIABLE_LENGTH = 1000000; + private const MAX_VARIABLE_LENGTH = 1_000_000; - private const MAX_TEMPLATE_EXPRESSIONS = 10000; + private const MAX_TEMPLATE_EXPRESSIONS = 10_000; - private const MAX_REGEX_LENGTH = 1000000; + private const MAX_REGEX_LENGTH = 1_000_000; private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*{[^{}]+}.*/'; From 6a999b4a400a742c22f5dba902eecd4bc6f35f79 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 27 Nov 2025 00:35:39 +0530 Subject: [PATCH 29/33] Update the test coverage threshold --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b4ee54b4..5313e3c3 100644 --- a/composer.json +++ b/composer.json @@ -80,7 +80,7 @@ "pint --test", "rector --dry-run" ], - "test:unit": "pest --ci --coverage --min=91.7", + "test:unit": "pest --ci --coverage --min=92.5", "test:types": "phpstan", "test": [ "@test:lint", From de08e11a89f1d6c005ca26db4b34284d0c054bd6 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 27 Nov 2025 00:41:32 +0530 Subject: [PATCH 30/33] Remove redundant test --- tests/Unit/Resources/ResourceTemplateTest.php | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/tests/Unit/Resources/ResourceTemplateTest.php b/tests/Unit/Resources/ResourceTemplateTest.php index d3322fda..2a57661d 100644 --- a/tests/Unit/Resources/ResourceTemplateTest.php +++ b/tests/Unit/Resources/ResourceTemplateTest.php @@ -6,24 +6,6 @@ use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; -it('compiles URI template and extracts variable names', function (): void { - $resource = new class extends Resource implements SupportsUriTemplate - { - public function uriTemplate(): UriTemplate - { - return new UriTemplate('file://users/{userId}/files/{fileId}'); - } - - public function handle(Request $request): Response - { - return Response::text('test'); - } - }; - - expect($resource->uri())->toBe('file://users/{userId}/files/{fileId}') - ->and($resource)->toBeInstanceOf(SupportsUriTemplate::class); -}); - it('matches URIs against a template pattern', function (): void { $resource = new class extends Resource implements SupportsUriTemplate { @@ -156,20 +138,6 @@ public function handle(Request $request): Response ->and($resource->uriTemplate()->match('file://posts/123/files/abc'))->toBeNull(); }); -it('static resources do not identify as templates', function (): void { - $resource = new class extends Resource - { - protected string $uri = 'file://logs/app.log'; - - public function handle(): Response - { - return Response::text('log content'); - } - }; - - expect($resource)->not->toBeInstanceOf(SupportsUriTemplate::class); -}); - it('end to end template reads uri extracts variables and returns response', function (): void { $template = new class extends Resource implements SupportsUriTemplate { From 8ed0c256fcf7b9f49dc671bccc838bd11156163d Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Fri, 28 Nov 2025 14:35:55 -0600 Subject: [PATCH 31/33] formatting --- .../Commands/MakeResourceTemplateCommand.php | 43 ------------------- src/Server/Contracts/SupportsUriTemplate.php | 12 ------ src/Server/Methods/ReadResource.php | 6 +-- src/Server/Resource.php | 6 +-- src/Server/ServerContext.php | 8 ++-- stubs/resource-template.stub | 37 ---------------- .../MakeResourceTemplateCommandTest.php | 20 --------- tests/Fixtures/ExampleResourceTemplate.php | 4 +- .../Methods/ListResourceTemplatesTest.php | 6 +-- tests/Unit/Methods/ListResourcesTest.php | 6 +-- tests/Unit/Methods/ReadResourceTest.php | 28 ++++++------ tests/Unit/Resources/ResourceTemplateTest.php | 16 +++---- 12 files changed, 40 insertions(+), 152 deletions(-) delete mode 100644 src/Console/Commands/MakeResourceTemplateCommand.php delete mode 100644 src/Server/Contracts/SupportsUriTemplate.php delete mode 100644 stubs/resource-template.stub delete mode 100644 tests/Feature/Console/MakeResourceTemplateCommandTest.php diff --git a/src/Console/Commands/MakeResourceTemplateCommand.php b/src/Console/Commands/MakeResourceTemplateCommand.php deleted file mode 100644 index fd444974..00000000 --- a/src/Console/Commands/MakeResourceTemplateCommand.php +++ /dev/null @@ -1,43 +0,0 @@ -laravel->basePath('stubs/resource-template.stub')) - ? $customPath - : __DIR__.'/../../../stubs/resource-template.stub'; - } - - protected function getDefaultNamespace($rootNamespace): string - { - return "{$rootNamespace}\\Mcp\\Resources"; - } - - /** - * @return array> - */ - protected function getOptions(): array - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the resource template already exists'], - ]; - } -} diff --git a/src/Server/Contracts/SupportsUriTemplate.php b/src/Server/Contracts/SupportsUriTemplate.php deleted file mode 100644 index 448b1465..00000000 --- a/src/Server/Contracts/SupportsUriTemplate.php +++ /dev/null @@ -1,12 +0,0 @@ -resources()->first(fn (Resource $resource): bool => $resource->uri() === $uri) ?? - $context->resourceTemplates()->first(fn (SupportsUriTemplate $template): bool => ! is_null($template->uriTemplate()->match($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); @@ -73,7 +73,7 @@ protected function invokeResource(Resource $resource, string $uri): mixed $request = $container->make(Request::class); $request->setUri($uri); - if ($resource instanceof SupportsUriTemplate) { + if ($resource instanceof HasUriTemplate) { $variables = $resource->uriTemplate()->match($uri) ?? []; $request->merge($variables); } diff --git a/src/Server/Resource.php b/src/Server/Resource.php index 2e7fb32c..1bf23101 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -7,7 +7,7 @@ use Illuminate\Support\Str; use Laravel\Mcp\Server\Annotations\Annotation; use Laravel\Mcp\Server\Concerns\HasAnnotations; -use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; +use Laravel\Mcp\Server\Contracts\HasUriTemplate; abstract class Resource extends Primitive { @@ -19,7 +19,7 @@ abstract class Resource extends Primitive public function uri(): string { - if ($this instanceof SupportsUriTemplate) { + if ($this instanceof HasUriTemplate) { return (string) $this->uriTemplate(); } @@ -67,7 +67,7 @@ public function toArray(): array $data['annotations'] = $annotations; } - if ($this instanceof SupportsUriTemplate) { + if ($this instanceof HasUriTemplate) { $data['uriTemplate'] = (string) $this->uriTemplate(); } else { $data['uri'] = $this->uri(); diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 432caae1..ab420e67 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -6,7 +6,7 @@ use Illuminate\Container\Container; use Illuminate\Support\Collection; -use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; +use Laravel\Mcp\Server\Contracts\HasUriTemplate; class ServerContext { @@ -56,11 +56,11 @@ public function resources(): Collection } /** - * @return Collection + * @return Collection */ public function resourceTemplates(): Collection { - /** @var Collection $resourceTemplates */ + /** @var Collection $resourceTemplates */ $resourceTemplates = collect($this->resources) ->filter(fn (Resource|string $resource): bool => $this->isResourceTemplate($resource)); @@ -99,6 +99,6 @@ private function resolvePrimitives(Collection $primitive): Collection private function isResourceTemplate(Resource|string $resource): bool { - return $resource instanceof SupportsUriTemplate || (is_string($resource) && is_subclass_of($resource, SupportsUriTemplate::class)); + return $resource instanceof HasUriTemplate || (is_string($resource) && is_subclass_of($resource, HasUriTemplate::class)); } } diff --git a/stubs/resource-template.stub b/stubs/resource-template.stub deleted file mode 100644 index a3caa8ee..00000000 --- a/stubs/resource-template.stub +++ /dev/null @@ -1,37 +0,0 @@ -get('id'); - - return Response::text("Resource content for ID: {$id}"); - } -} diff --git a/tests/Feature/Console/MakeResourceTemplateCommandTest.php b/tests/Feature/Console/MakeResourceTemplateCommandTest.php deleted file mode 100644 index 00be440f..00000000 --- a/tests/Feature/Console/MakeResourceTemplateCommandTest.php +++ /dev/null @@ -1,20 +0,0 @@ -artisan('make:mcp-resource-template', [ - 'name' => 'TestResourceTemplate', - ]); - - $response->assertExitCode(0)->run(); - - $this->assertFileExists(app_path('Mcp/Resources/TestResourceTemplate.php')); -}); - -it('may publish a custom stub', function (): void { - $this->artisan('vendor:publish', [ - '--tag' => 'mcp-stubs', - '--force' => true, - ])->assertExitCode(0)->run(); - - $this->assertFileExists(base_path('stubs/resource-template.stub')); -}); diff --git a/tests/Fixtures/ExampleResourceTemplate.php b/tests/Fixtures/ExampleResourceTemplate.php index 695130dc..a97b03b2 100644 --- a/tests/Fixtures/ExampleResourceTemplate.php +++ b/tests/Fixtures/ExampleResourceTemplate.php @@ -6,11 +6,11 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; +use Laravel\Mcp\Server\Contracts\HasUriTemplate; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; -class ExampleResourceTemplate extends Resource implements SupportsUriTemplate +class ExampleResourceTemplate extends Resource implements HasUriTemplate { protected string $description = 'Example resource template for testing'; diff --git a/tests/Unit/Methods/ListResourceTemplatesTest.php b/tests/Unit/Methods/ListResourceTemplatesTest.php index ec09810a..d9b64693 100644 --- a/tests/Unit/Methods/ListResourceTemplatesTest.php +++ b/tests/Unit/Methods/ListResourceTemplatesTest.php @@ -2,7 +2,7 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; +use Laravel\Mcp\Server\Contracts\HasUriTemplate; use Laravel\Mcp\Server\Methods\ListResourceTemplates; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\ServerContext; @@ -20,7 +20,7 @@ public function handle(): Response } }; - $templateResource = new class extends Resource implements SupportsUriTemplate + $templateResource = new class extends Resource implements HasUriTemplate { protected string $mimeType = 'text/plain'; @@ -108,7 +108,7 @@ public function handle(): Response }); it('includes template metadata in the listing', function (): void { - $templateResource = new class extends Resource implements SupportsUriTemplate + $templateResource = new class extends Resource implements HasUriTemplate { protected string $name = 'user-file'; diff --git a/tests/Unit/Methods/ListResourcesTest.php b/tests/Unit/Methods/ListResourcesTest.php index 62c1fab1..f25ecc8d 100644 --- a/tests/Unit/Methods/ListResourcesTest.php +++ b/tests/Unit/Methods/ListResourcesTest.php @@ -8,7 +8,7 @@ use Laravel\Mcp\Server\Annotations\Audience; use Laravel\Mcp\Server\Annotations\LastModified; use Laravel\Mcp\Server\Annotations\Priority; -use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; +use Laravel\Mcp\Server\Contracts\HasUriTemplate; use Laravel\Mcp\Server\Methods\ListResources; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\ServerContext; @@ -248,7 +248,7 @@ public function handle(): string }); it('excludes resource templates from list', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -288,7 +288,7 @@ public function handle(Request $request): Response it('returns only static resources when both templates and static resources exist', function (): void { $staticResource = $this->makeResource(); - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index c4bfd55a..90a12f8c 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -5,7 +5,7 @@ use Illuminate\Container\Container; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; +use Laravel\Mcp\Server\Contracts\HasUriTemplate; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\ReadResource; use Laravel\Mcp\Server\Resource; @@ -87,7 +87,7 @@ }); it('reads resource template by matching a URI pattern', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -121,7 +121,7 @@ public function handle(Request $request): Response }); it('returns the actual requested URI in response, not the template pattern', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -154,7 +154,7 @@ public function handle(Request $request): Response }); it('extracts variables from URI template and passes to handler', function (string $templatePattern, string $uri, array $expected): void { - $resource = new class($templatePattern) extends Resource implements SupportsUriTemplate + $resource = new class($templatePattern) extends Resource implements HasUriTemplate { public function __construct(private string $pattern) {} @@ -200,7 +200,7 @@ public function handle(Request $request): Response ]); it('preserves sessionId and meta from the original request for template resources', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -254,7 +254,7 @@ public function handle(Request $request): Response }); it('template handler receives variables via request get method', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -293,7 +293,7 @@ public function handle(Request $request): Response it('tries static resources before template matching', function (): void { $staticResource = $this->makeResource('Static resource content'); - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -327,7 +327,7 @@ public function handle(Request $request): Response }); it('returns the first matching template when multiple templates exist', function (): void { - $template1 = new class extends Resource implements SupportsUriTemplate + $template1 = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -340,7 +340,7 @@ public function handle(Request $request): Response } }; - $template2 = new class extends Resource implements SupportsUriTemplate + $template2 = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -377,7 +377,7 @@ public function handle(Request $request): Response $this->expectException(JsonRpcException::class); $this->expectExceptionMessage('Resource [file://posts/123] not found.'); - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -454,7 +454,7 @@ public function handle(Request $request): Response }); it('does not leak variables between consecutive template resource requests', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -504,7 +504,7 @@ public function handle(Request $request): Response }); it('sets uri on request when reading resource templates', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -538,7 +538,7 @@ public function handle(Request $request): Response }); it('provides both uri and extracted variables in request for templates', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -581,7 +581,7 @@ public function handle(Request $request): Response it('uri is correctly set and isolated for consecutive requests', function (string $resourceType, string $firstUri, string $secondUri): void { if ($resourceType === 'template') { - $resource = new class extends Resource implements SupportsUriTemplate + $resource = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { diff --git a/tests/Unit/Resources/ResourceTemplateTest.php b/tests/Unit/Resources/ResourceTemplateTest.php index 2a57661d..80d723bd 100644 --- a/tests/Unit/Resources/ResourceTemplateTest.php +++ b/tests/Unit/Resources/ResourceTemplateTest.php @@ -2,12 +2,12 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Contracts\SupportsUriTemplate; +use Laravel\Mcp\Server\Contracts\HasUriTemplate; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Support\UriTemplate; it('matches URIs against a template pattern', function (): void { - $resource = new class extends Resource implements SupportsUriTemplate + $resource = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -28,7 +28,7 @@ public function handle(Request $request): Response }); it('extracts variables from matching URI', function (): void { - $resource = new class extends Resource implements SupportsUriTemplate + $resource = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -51,7 +51,7 @@ public function handle(Request $request): Response }); it('handles template resource with extracted variables', function (): void { - $resource = new class extends Resource implements SupportsUriTemplate + $resource = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -78,7 +78,7 @@ public function handle(Request $request): Response }); it('handles template with single variable', function (): void { - $resource = new class extends Resource implements SupportsUriTemplate + $resource = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -96,7 +96,7 @@ public function handle(Request $request): Response }); it('handles complex URI templates with multiple path segments', function (): void { - $resource = new class extends Resource implements SupportsUriTemplate + $resource = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -120,7 +120,7 @@ public function handle(Request $request): Response }); it('does not match URIs with different path structure', function (): void { - $resource = new class extends Resource implements SupportsUriTemplate + $resource = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { @@ -139,7 +139,7 @@ public function handle(Request $request): Response }); it('end to end template reads uri extracts variables and returns response', function (): void { - $template = new class extends Resource implements SupportsUriTemplate + $template = new class extends Resource implements HasUriTemplate { public function uriTemplate(): UriTemplate { From bc7e70411a662118557c7b4f1182519fd62673f5 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Fri, 28 Nov 2025 14:36:02 -0600 Subject: [PATCH 32/33] add files --- src/Server/Contracts/HasUriTemplate.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Server/Contracts/HasUriTemplate.php diff --git a/src/Server/Contracts/HasUriTemplate.php b/src/Server/Contracts/HasUriTemplate.php new file mode 100644 index 00000000..4064e802 --- /dev/null +++ b/src/Server/Contracts/HasUriTemplate.php @@ -0,0 +1,15 @@ + Date: Fri, 28 Nov 2025 14:37:13 -0600 Subject: [PATCH 33/33] formatting --- src/Server/McpServiceProvider.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index 4a9de98a..b97df633 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -9,7 +9,6 @@ use Laravel\Mcp\Console\Commands\InspectorCommand; use Laravel\Mcp\Console\Commands\MakePromptCommand; use Laravel\Mcp\Console\Commands\MakeResourceCommand; -use Laravel\Mcp\Console\Commands\MakeResourceTemplateCommand; use Laravel\Mcp\Console\Commands\MakeServerCommand; use Laravel\Mcp\Console\Commands\MakeToolCommand; use Laravel\Mcp\Console\Commands\StartCommand; @@ -95,7 +94,6 @@ protected function registerCommands(): void MakeToolCommand::class, MakePromptCommand::class, MakeResourceCommand::class, - MakeResourceTemplateCommand::class, InspectorCommand::class, ]); }