diff --git a/src/Server.php b/src/Server.php index a6012db..e36638a 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; @@ -92,6 +93,7 @@ abstract class Server 'tools/list' => ListTools::class, 'tools/call' => CallTool::class, 'resources/list' => ListResources::class, + 'resources/templates/list' => ListResourceTemplates::class, 'resources/read' => ReadResource::class, 'prompts/list' => ListPrompts::class, 'prompts/get' => GetPrompt::class, diff --git a/src/Server/Methods/ListResourceTemplates.php b/src/Server/Methods/ListResourceTemplates.php new file mode 100644 index 0000000..da77949 --- /dev/null +++ b/src/Server/Methods/ListResourceTemplates.php @@ -0,0 +1,25 @@ +resourceTemplates(), + perPage: $context->perPage($request->get('per_page')), + cursor: $request->cursor(), + ); + + return JsonRpcResponse::result($request->id, $paginator->paginate('resourceTemplates')); + } +} diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index 50718a1..9023e7d 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\Resources\Uri; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; @@ -37,9 +39,37 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat ); } - $resource = $context->resources() + $resource = $context->resources(true) ->first( - fn (Resource $resource): bool => $resource->uri() === $request->get('uri'), + function (Resource $resource) use ($request): bool { + $matches = $resource->match($request->get('uri')); + + if ($matches) { + $resource->setActualUri($request->get('uri')); + + // Add the path variables to the mcp request only if + // the resource is a template since resources can + // only have path variables if they are templates + if ( + $resource->isTemplate() + && ($pathVariables = Uri::pathVariables($resource->originalUri(), $resource->uri())) !== [] + ) { + $container = Container::getInstance(); + + if ($container->has('mcp.request')) { + /** @var Request $request */ + $request = $container->make('mcp.request'); + + $request->setArguments([ + ...$request->all(), + ...$pathVariables, + ]); + } + } + } + + return $matches; + }, fn () => throw new JsonRpcException( "Resource [{$request->get('uri')}] not found.", -32002, diff --git a/src/Server/Resource.php b/src/Server/Resource.php index 26b2eac..91b26bc 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -5,14 +5,53 @@ namespace Laravel\Mcp\Server; use Illuminate\Support\Str; +use Laravel\Mcp\Server\Resources\Matching\SchemeValidator; +use Laravel\Mcp\Server\Resources\Matching\UriValidator; +use Laravel\Mcp\Server\Resources\Matching\ValidatorInterface; +use Laravel\Mcp\Server\Resources\Uri; abstract class Resource extends Primitive { protected string $uri = ''; + private string $actualUri = ''; + protected string $mimeType = ''; + /** + * We can use this property to make the method + * $this->isTemplate() more efficient and + * avoid compiling the regex of the uri + */ + protected ?bool $isTemplate = null; + + /** + * @var array + */ + public static array $validators; + + /** + * @return array + */ + public static function getValidators(): array + { + // To match the route, we will use a chain of responsibility pattern with the + // validator implementations. We will spin through each one making sure it + // passes, and then we will know if the route as a whole matches request. + return static::$validators ?? static::$validators = [ + new UriValidator, new SchemeValidator, + ]; + } + public function uri(): string + { + return + $this->actualUri !== '' + ? $this->actualUri + : $this->originalUri(); + } + + public function originalUri(): string { return $this->uri !== '' ? $this->uri @@ -26,12 +65,55 @@ public function mimeType(): string : 'text/plain'; } + public function setActualUri(string $uri): static + { + $this->actualUri = $uri; + + return $this; + } + + public function match(string $uri): bool + { + if ($this->uri() === $uri) { + return true; + } + + foreach (self::getValidators() as $validator) { + if (! $validator->matches($this, $uri)) { + return false; + } + } + + return true; + } + + public function isTemplate(): bool + { + return $this->isTemplate ?? count(Uri::pathRegex($this->originalUri())['variables']) > 0; + } + + public function getUriPath(): string + { + return Uri::path($this->uri()); + } + + public function getUriScheme(): string + { + return Uri::scheme($this->uri()); + } + /** * @return array */ public function toMethodCall(): array { - return ['uri' => $this->uri()]; + $response = ['uri' => $this->uri()]; + + if ($this->isTemplate()) { + $response['uriTemplate'] = $this->originalUri(); + } + + return $response; } public function toArray(): array @@ -40,8 +122,8 @@ public function toArray(): array 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), - 'uri' => $this->uri(), 'mimeType' => $this->mimeType(), + ...$this->toMethodCall(), ]; } } diff --git a/src/Server/Resources/Matching/SchemeValidator.php b/src/Server/Resources/Matching/SchemeValidator.php new file mode 100644 index 0000000..4544b40 --- /dev/null +++ b/src/Server/Resources/Matching/SchemeValidator.php @@ -0,0 +1,16 @@ +getUriScheme() === Uri::scheme($uri); + } +} diff --git a/src/Server/Resources/Matching/UriValidator.php b/src/Server/Resources/Matching/UriValidator.php new file mode 100644 index 0000000..4c5c6a2 --- /dev/null +++ b/src/Server/Resources/Matching/UriValidator.php @@ -0,0 +1,18 @@ +uri())['regex'], rawurldecode($path)) === 1; + } +} diff --git a/src/Server/Resources/Matching/ValidatorInterface.php b/src/Server/Resources/Matching/ValidatorInterface.php new file mode 100644 index 0000000..e3a0925 --- /dev/null +++ b/src/Server/Resources/Matching/ValidatorInterface.php @@ -0,0 +1,12 @@ + + */ + public static function pathRegex(string $uri): array + { + $path = Uri::path($uri); + + $tokens = []; + $variables = []; + $matches = []; + $pos = 0; + $defaultSeparator = '/'; + $useUtf8 = preg_match('//u', $path) !== false; + + // Match all variables enclosed in "{}" and iterate over them. But we only want to match the innermost variable + // in case of nested "{}", e.g. {foo{bar}}. This in ensured because \w does not match "{" or "}" itself. + preg_match_all('#\{(!)?([\w\x80-\xFF]+)\}#', $path, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); + + foreach ($matches as $match) { + $important = $match[1][1] >= 0; + $varName = $match[2][0]; + // get all static text preceding the current variable + $precedingText = substr($path, $pos, $match[0][1] - $pos); + $pos = $match[0][1] + \strlen($match[0][0]); + + if ($precedingText === '') { + $precedingChar = ''; + } elseif ($useUtf8) { + preg_match('/.$/u', $precedingText, $precedingChar); + /** @phpstan-ignore offsetAccess.notFound */ + $precedingChar = $precedingChar[0]; + } else { + $precedingChar = substr($precedingText, -1); + } + + $isSeparator = $precedingChar !== '' && str_contains((string) static::SEPARATORS, $precedingChar); + + // A PCRE subpattern name must start with a non-digit. Also, a PHP variable cannot start with a digit so the + // variable would not be usable as a Controller action argument. + if (preg_match('/^\d/', $varName)) { + throw new DomainException(\sprintf('Variable name "%s" cannot start with a digit in URI pattern "%s". Please use a different name.', $varName, $path)); + } + + if (\in_array($varName, $variables, true)) { + throw new LogicException(\sprintf('URI pattern "%s" cannot reference variable name "%s" more than once.', $path, $varName)); + } + + if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) { + throw new DomainException(\sprintf('Variable name "%s" cannot be longer than %d characters in URI pattern "%s". Please use a shorter name.', $varName, self::VARIABLE_MAXIMUM_LENGTH, $path)); + } + + if ($isSeparator && $precedingText !== $precedingChar) { + $tokens[] = ['text', substr($precedingText, 0, -\strlen($precedingChar))]; + } elseif (! $isSeparator && $precedingText !== '') { + $tokens[] = ['text', $precedingText]; + } + + $followingPattern = substr($path, $pos); + + // Find the next static character after the variable that functions as a separator. By default, this separator and '/' + // are disallowed for the variable. This default requirement makes sure that optional variables can be matched at all + // and that the generating-matching-combination of URLs unambiguous, i.e. the params used for generating the URL are + // the same that will be matched. Example: new Route('/{page}.{_format}', ['_format' => 'html']) + // If {page} would also match the separating dot, {_format} would never match as {page} will eagerly consume everything. + // Also, even if {_format} was not optional the requirement prevents that {page} matches something that was originally + // part of {_format} when generating the URL, e.g. _format = 'mobile.html'. + $nextSeparator = self::findNextSeparator($followingPattern, $useUtf8); + + $regexp = \sprintf( + '[^%s%s]+', + preg_quote($defaultSeparator), + $defaultSeparator !== $nextSeparator && $nextSeparator !== '' ? preg_quote($nextSeparator) : '' + ); + + if (($nextSeparator !== '' && in_array(preg_match('#^\{[\w\x80-\xFF]+\}#', $followingPattern), [0, false], true)) || $followingPattern === '') { + // When we have a separator, which is disallowed for the variable, we can optimize the regex with a possessive + // quantifier. This prevents useless backtracking of PCRE and improves performance by 20% for matching those patterns. + // Given the above example, there is no point in backtracking into {page} (that forbids the dot) when a dot must follow + // after it. This optimization cannot be applied when the next char is no real separator or when the next variable is + // directly adjacent, e.g. '/{x}{y}'. + $regexp .= '+'; + } + + if ($important) { + $token = ['variable', $isSeparator ? $precedingChar : '', $regexp, $varName, false, true]; + } else { + $token = ['variable', $isSeparator ? $precedingChar : '', $regexp, $varName]; + } + + $tokens[] = $token; + $variables[] = $varName; + } + + if ($pos < \strlen($path)) { + $tokens[] = ['text', substr($path, $pos)]; + } + + // find the first optional token + $firstOptional = PHP_INT_MAX; + for ($i = \count($tokens) - 1; $i >= 0; $i--) { + $token = $tokens[$i]; + // variable is optional when it is not important and has a default value + if ($token[0] === 'variable' && ! ($token[5] ?? false)) { + $firstOptional = $i; + } else { + break; + } + } + + // compute the matching regexp + $regexp = ''; + for ($i = 0, $nbToken = \count($tokens); $i < $nbToken; $i++) { + $regexp .= self::computeRegexp($tokens, $i, $firstOptional); + } + + $regexp = '{^'.$regexp.'$}sD'; + + // enable Utf8 matching + $regexp .= 'u'; + for ($i = 0, $nbToken = \count($tokens); $i < $nbToken; $i++) { + if ($tokens[$i][0] === 'variable') { + $tokens[$i][4] = true; + } + } + + return [ + 'staticPrefix' => self::determineStaticPrefix($tokens), + 'regex' => $regexp, + 'tokens' => array_reverse($tokens), + 'variables' => $variables, + ]; + } + + /** + * @return array + */ + public static function pathVariables(string $templateUri, string $uri): array + { + $path = static::path($uri); + $regex = static::pathRegex($templateUri); + + if (count($regex['variables']) === 0) { + return []; + } + + preg_match($regex['regex'], $path, $matches); + + $values = array_slice($matches, 1); + + return array_intersect_key($values, array_flip($regex['variables'])); + } + + public static function scheme(string $uri): string + { + return parse_url($uri, PHP_URL_SCHEME) ?: throw new RuntimeException('Invalid URI provided'); + } + + /** + * @param list> $tokens + */ + private static function computeRegexp(array $tokens, int $index, int $firstOptional): string + { + $token = $tokens[$index]; + if ($token[0] === 'text') { + // Text tokens + return preg_quote((string) $token[1]); + } + + // Variable tokens + if ($index === 0 && $firstOptional === 0) { + // When the only token is an optional variable token, the separator is required + return \sprintf('%s(?P<%s>%s)?', preg_quote((string) $token[1]), $token[3], $token[2]); + } + + $regexp = \sprintf('%s(?P<%s>%s)', preg_quote((string) $token[1]), $token[3], $token[2]); + if ($index >= $firstOptional) { + // Enclose each optional token in a subpattern to make it optional. + // "?:" means it is non-capturing, i.e. the portion of the subject string that + // matched the optional subpattern is not passed back. + $regexp = "(?:{$regexp}"; + $nbTokens = \count($tokens); + if ($nbTokens - 1 === $index) { + // Close the optional subpatterns + $regexp .= str_repeat(')?', $nbTokens - $firstOptional - ($firstOptional === 0 ? 1 : 0)); + } + } + + return $regexp; + } + + /** + * @param list> $tokens + */ + private static function determineStaticPrefix(array $tokens): string + { + if ($tokens[0][0] !== 'text') { + return (string) ($tokens[0][1] === '/' ? '' : $tokens[0][1]); + } + + $prefix = $tokens[0][1]; + + if (isset($tokens[1][1]) && $tokens[1][1] !== '/') { + $prefix .= $tokens[1][1]; + } + + return (string) $prefix; + } + + private static function findNextSeparator(string $pattern, bool $useUtf8): string + { + if ($pattern === '') { + // return empty string if pattern is empty or false (false which can be returned by substr) + return ''; + } + + // first remove all placeholders from the pattern so we can find the next real static character + if ('' === $pattern = preg_replace('#\{[\w\x80-\xFF]+\}#', '', $pattern)) { + return ''; + } + + if ($useUtf8) { + preg_match('/^./u', (string) $pattern, $pattern); + } + + /** @phpstan-ignore offsetAccess.notFound, offsetAccess.notFound */ + return str_contains((string) static::SEPARATORS, $pattern[0]) ? $pattern[0] : ''; + } +} diff --git a/src/Server/ServerContext.php b/src/Server/ServerContext.php index 7994e03..f8dbd0d 100644 --- a/src/Server/ServerContext.php +++ b/src/Server/ServerContext.php @@ -45,13 +45,25 @@ public function tools(): Collection /** * @return Collection */ - public function resources(): Collection + public function resources(bool $includeTemplates = false): Collection { return collect($this->resources)->map( fn (Resource|string $resourceClass) => is_string($resourceClass) ? Container::getInstance()->make($resourceClass) : $resourceClass - )->filter(fn (Resource $resource): bool => $resource->eligibleForRegistration()); + )->filter(fn (Resource $resource): bool => ($includeTemplates || ! $resource->isTemplate()) && $resource->eligibleForRegistration()); + } + + /** + * @return Collection + */ + public function resourceTemplates(): Collection + { + return collect($this->resources)->map( + fn (Resource|string $resourceClass) => is_string($resourceClass) + ? Container::getInstance()->make($resourceClass) + : $resourceClass + )->filter(fn (Resource $resource): bool => $resource->isTemplate() && $resource->eligibleForRegistration()); } /** diff --git a/src/Server/Testing/PendingTestResponse.php b/src/Server/Testing/PendingTestResponse.php index e452d09..216af1e 100644 --- a/src/Server/Testing/PendingTestResponse.php +++ b/src/Server/Testing/PendingTestResponse.php @@ -84,11 +84,20 @@ protected function run(string $method, Primitive|string $primitive, array $argum $requestId = uniqid(); + $parameters = $primitive->toMethodCall(); + + foreach ($arguments as $key => $value) { + if (array_key_exists($key, $parameters)) { + $parameters[$key] = $value; + unset($arguments[$key]); + } + } + $request = new JsonRpcRequest( $requestId, $method, [ - ...$primitive->toMethodCall(), + ...$parameters, 'arguments' => $arguments, ], ); diff --git a/src/Server/Testing/TestResponse.php b/src/Server/Testing/TestResponse.php index 8a79722..2d134aa 100644 --- a/src/Server/Testing/TestResponse.php +++ b/src/Server/Testing/TestResponse.php @@ -13,6 +13,7 @@ use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Transport\JsonRpcResponse; +use LogicException; use PHPUnit\Framework\Assert; use RuntimeException; @@ -58,6 +59,7 @@ public function assertSee(array|string $text): static $seeable = collect([ ...$this->content(), ...$this->errors(), + ...$this->responseUri(), ])->filter()->unique()->values()->all(); foreach (is_array($text) ? $text : [$text] as $segment) { @@ -85,6 +87,7 @@ public function assertDontSee(array|string $text): static $seeable = collect([ ...$this->content(), ...$this->errors(), + ...$this->responseUri(), ])->filter()->unique()->values()->all(); foreach (is_array($text) ? $text : [$text] as $segment) { @@ -162,6 +165,21 @@ public function assertDescription(string $description): static return $this; } + public function assertUri(string $uri): static + { + if (! ($this->premitive instanceof Resource)) { + throw new LogicException("You can't assert the URI on primitive other than [".Resource::class.'].'); + } + + Assert::assertEquals( + $uri, + $this->premitive->uri(), + "The expected uri [{$uri}] does not match the actual uri [{$this->premitive->uri()}].", + ); + + return $this; + } + public function assertOk(): static { return $this->assertHasNoErrors(); @@ -269,6 +287,21 @@ protected function content(): array })->filter()->unique()->values()->all(); } + /** + * @return array + */ + protected function responseUri(): array + { + return (match (true) { + $this->premitive instanceof Tool, + $this->premitive instanceof Prompt => collect(), + // @phpstan-ignore-next-line + $this->premitive instanceof Resource => collect($this->response->toArray()['result']['contents'] ?? []) + ->map(fn (array $item): string => $item['uri'] ?? ''), + default => throw new RuntimeException('This primitive type is not supported.'), + })->filter()->unique()->values()->all(); + } + /** * @return array */ diff --git a/tests/Feature/Testing/Resources/AssertSeeTest.php b/tests/Feature/Testing/Resources/AssertSeeTest.php index 04a0162..a08eab9 100644 --- a/tests/Feature/Testing/Resources/AssertSeeTest.php +++ b/tests/Feature/Testing/Resources/AssertSeeTest.php @@ -10,6 +10,8 @@ class HotelR extends Server { protected array $resources = [ BookingResource::class, + ServiceConfirmationCheckResource::class, + InvalidResourceTemplateResource::class, ]; } @@ -39,6 +41,44 @@ public function shouldRegister(Request $request): bool } } +class ServiceConfirmationCheckResource extends Resource +{ + protected string $uri = 'service://confirmation-check/{type}'; + + public function handle(Request $request): string + { + $type = $request->get('type'); + + if (in_array($type, ['restaurant', 'massage'], true)) { + return "Sorry, we could not the reservation for your {$type}."; + } + + return "Your {$type} reservation is confirmed!"; + } + + public function shouldRegister(Request $request): bool + { + return $request->boolean('register', true); + } +} + +class InvalidResourceTemplateResource extends Resource +{ + protected string $uri = 'invalid://optional-param/{param?}'; + + public function handle(Request $request): string + { + $param = $request->get('param'); + + return "Oops! something happened because optional param [{$param}] on resource templates are not allowed."; + } + + public function shouldRegister(Request $request): bool + { + return $request->boolean('register', true); + } +} + it('may assert that text is seen when returning string content', function (): void { $response = HotelR::resource(BookingResource::class); @@ -55,6 +95,29 @@ public function shouldRegister(Request $request): bool ->assertDontSee('Please select a more reasonable date.'); }); +it('may assert that text is seen when using templates', function (string $uri, string $message): void { + $response = HotelR::resource(ServiceConfirmationCheckResource::class, ['uri' => $uri]); + + $response + ->assertSee($message) + ->assertSee($uri) + ->assertUri('service://confirmation-check/{type}'); +})->with([ + ['service://confirmation-check/spa', 'Your spa reservation is confirmed!'], + ['service://confirmation-check/beach-dinner', 'Your beach-dinner reservation is confirmed!'], + ['service://confirmation-check/restaurant', 'Sorry, we could not the reservation for your restaurant.'], + ['service://confirmation-check/massage', 'Sorry, we could not the reservation for your massage.'], +]); + +it('may assert that resource is not found when using templates and not matching uris', function (): void { + $uri = 'service://confirmation-check/spa/extra-value'; + + $response = HotelR::resource(ServiceConfirmationCheckResource::class, ['uri' => $uri]); + + $response + ->assertHasErrors(["Resource [{$uri}] not found."]); +}); + it('may assert that text is seen when providing arguments that are wrong', function (): void { $response = HotelR::resource(BookingResource::class, ['date' => now()->subDay()->toDateString()]); @@ -70,6 +133,12 @@ public function shouldRegister(Request $request): bool $response->assertSee('This text is not present'); })->throws(ExpectationFailedException::class); +it('fails to assert that text is seen when optional params are used for the resource uri', function (): void { + $response = HotelR::resource(ServiceConfirmationCheckResource::class, ['uri' => 'invalid://optional-param/value']); + + $response->assertSee('Oops! something happened because optional param [value] on resource templates are not allowed.'); +})->throws(ExpectationFailedException::class); + it('fails to assert that text is not seen when it is present', function (): void { $response = HotelR::resource(BookingResource::class); diff --git a/tests/TestCase.php b/tests/TestCase.php index 3eca350..99d9da2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -83,7 +83,13 @@ public function __construct( private string $desc, private array $overrides, ) { - // + if ($this->overrides['uri'] ?? false) { + $this->uri = $this->overrides['uri']; + } + + if ($this->overrides['mimeType'] ?? false) { + $this->mimeType = $this->overrides['mimeType']; + } } public function description(): string @@ -95,16 +101,6 @@ public function handle(): string { return $this->contentValue; } - - public function uri(): string - { - return $this->overrides['uri'] ?? parent::uri(); - } - - public function mimeType(): string - { - return $this->overrides['mimeType'] ?? parent::mimeType(); - } }; } diff --git a/tests/Unit/Resources/ListResourceTemplatesTest.php b/tests/Unit/Resources/ListResourceTemplatesTest.php new file mode 100644 index 0000000..cca54be --- /dev/null +++ b/tests/Unit/Resources/ListResourceTemplatesTest.php @@ -0,0 +1,143 @@ +getServerContext(); + $jsonRpcRequest = new JsonRpcRequest(id: 1, method: 'resources/templates/list', params: []); + $result = $listResources->handle($jsonRpcRequest, $context); + + $this->assertMethodResult([ + 'resourceTemplates' => [], + ], $result); +}); + +it('returns a valid populated list resource templates response', function (): void { + $listResources = new ListResourceTemplates; + $resource = $this->makeResource(overrides: ['uri' => 'file://resource/{type}']); + + $context = $this->getServerContext([ + 'resources' => [ + $resource, + ], + ]); + $jsonRpcRequest = new JsonRpcRequest(id: 1, method: 'resources/templates/list', params: []); + + $this->assertMethodResult([ + 'resourceTemplates' => [ + [ + 'name' => $resource->name(), + 'title' => $resource->title(), + 'description' => $resource->description(), + 'uri' => $resource->uri(), + 'uriTemplate' => $resource->originalUri(), + 'mimeType' => $resource->mimeType(), + ], + ], + ], $listResources->handle($jsonRpcRequest, $context)); +}); + +it('returns empty list when the single resource template is not eligible for registration', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'list-resources', + 'params' => [], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [], + resources: [new class extends Resource + { + protected string $uri = 'file://resource/{type}'; + + public function handle(): string + { + return 'foo'; + } + + public function shouldRegister(): bool + { + return false; + } + }], + prompts: [], + ); + + $listResources = new ListResourceTemplates; + + $response = $listResources->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toEqual([ + 'resourceTemplates' => [], + ]); +}); + +it('returns empty list when the single resource template is not eligible for registration via request', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/templates/list', + 'params' => [ + 'arguments' => ['register_resources' => false], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [], + resources: [new class extends Resource + { + protected string $uri = 'file://resource/{type}'; + + public function handle(): string + { + return 'foo'; + } + + public function shouldRegister(Request $request): bool + { + return $request->get('register_resources', true); + } + }], + prompts: [], + ); + + $listResources = new ListResourceTemplates; + + $this->instance('mcp.request', $request->toRequest()); + $response = $listResources->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toEqual([ + 'resourceTemplates' => [], + ]); +}); diff --git a/tests/Unit/Resources/ReadResourceTest.php b/tests/Unit/Resources/ReadResourceTest.php index 3eb73e8..8a8b326 100644 --- a/tests/Unit/Resources/ReadResourceTest.php +++ b/tests/Unit/Resources/ReadResourceTest.php @@ -26,6 +26,27 @@ ], ], $resourceResult); }); + +it('returns a valid resource result from template', function (): void { + $resource = $this->makeResource(overrides: ['uri' => 'file://resource/{type}']); + $readResource = new ReadResource; + $context = $this->getServerContext([ + 'resources' => [ + $resource, + ], + ]); + $jsonRpcRequest = new JsonRpcRequest(id: 1, method: 'resources/read', params: ['uri' => 'file://resource/template']); + $resourceResult = $readResource->handle($jsonRpcRequest, $context); + + $this->assertPartialMethodResult([ + 'contents' => [ + [ + 'text' => 'resource-content', + ], + ], + ], $resourceResult); +}); + it('returns a valid resource result for blob resources', function (): void { $resource = $this->makeBinaryResource(__DIR__.'/../../Fixtures/binary.png'); $readResource = new ReadResource; @@ -79,3 +100,24 @@ $response = $readResource->handle($jsonRpcRequest, $context); }); + +it('throws exception when resource template is not found', function (): void { + $this->expectException(JsonRpcException::class); + $this->expectExceptionMessage('Resource [file://resource/template/extra] not found.'); + + $resource = $this->makeResource(overrides: ['uri' => 'file://resource/{type}']); + $readResource = new ReadResource; + $context = $this->getServerContext([ + 'resources' => [ + $resource, + ], + ]); + + $jsonRpcRequest = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => 'file://resource/template/extra'] + ); + + $response = $readResource->handle($jsonRpcRequest, $context); +}); diff --git a/tests/Unit/Resources/UriTest.php b/tests/Unit/Resources/UriTest.php new file mode 100644 index 0000000..383b47c --- /dev/null +++ b/tests/Unit/Resources/UriTest.php @@ -0,0 +1,34 @@ +toBe('resource/path'); +}); + +it('returns a valid path regex from a uri', function (): void { + $uri = 'file://resource/path'; + $result = Uri::pathRegex($uri); + + expect($result)->toBe([ + 'staticPrefix' => 'resource/path', + 'regex' => '{^resource/path$}sDu', + 'tokens' => [ + [ + 'text', + 'resource/path', + ], + ], + 'variables' => [], + ]); +}); + +it('returns a valid path even if the regex is at the beginning', function (): void { + $uri = '{variable}/resource/path'; + $path = Uri::pathRegex($uri); + + expect($path['variables'])->toBe(['variable']); +});