Skip to content

Commit 742837a

Browse files
pushpak1300zacksmashtaylorotwell
authored
Add Support For Resource Templates (#113)
* Add Resource Template * Fix code styling * Add _meta support (#106) * 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 <[email protected]> # Conflicts: # tests/Unit/Methods/ReadResourceTest.php * 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% * Update CHANGELOG * Merge branch 'main' into add_support_for_resorce_templatees # Conflicts: # tests/Unit/Methods/ReadResourceTest.php * Fix code styling * Fix test * Refactor * Refactor * Add Test * Refactor * Add Test * Refactor UriTemplate methods * Refactor * Refactor * Refactor * Refactor * Add More Test * Add make:: method * Refactor Test * Refactor * Refactor * Fix Test * Refactor tools variable name * Formatting * SupportUriTemplate -> SupportsUriTemplate * improve numeric readability * Update the test coverage threshold * Remove redundant test * formatting * add files * formatting --------- Co-authored-by: zacksmash <[email protected]> Co-authored-by: taylorotwell <[email protected]> Co-authored-by: Taylor Otwell <[email protected]>
1 parent 2e662d0 commit 742837a

18 files changed

+1535
-38
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"pint --test",
8181
"rector --dry-run"
8282
],
83-
"test:unit": "pest --ci --coverage --min=92",
83+
"test:unit": "pest --ci --coverage --min=92.5",
8484
"test:types": "phpstan",
8585
"test": [
8686
"@test:lint",

src/Request.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public function __construct(
3030
protected array $arguments = [],
3131
protected ?string $sessionId = null,
3232
protected ?array $meta = null,
33+
protected ?string $uri = null,
3334
) {
3435
//
3536
}
@@ -61,6 +62,16 @@ public function get(string $key, mixed $default = null): mixed
6162
return $this->data($key, $default);
6263
}
6364

65+
/**
66+
* @param array<string,mixed> $data
67+
*/
68+
public function merge(array $data): static
69+
{
70+
$this->arguments = array_merge($this->arguments, $data);
71+
72+
return $this;
73+
}
74+
6475
/**
6576
* @return array<string, mixed>
6677
*/
@@ -102,6 +113,11 @@ public function meta(): ?array
102113
return $this->meta;
103114
}
104115

116+
public function uri(): ?string
117+
{
118+
return $this->uri;
119+
}
120+
105121
/**
106122
* @param array<string, mixed> $arguments
107123
*/
@@ -122,4 +138,9 @@ public function setMeta(?array $meta): void
122138
{
123139
$this->meta = $meta;
124140
}
141+
142+
public function setUri(?string $uri): void
143+
{
144+
$this->uri = $uri;
145+
}
125146
}

src/Server.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Laravel\Mcp\Server\Methods\Initialize;
1515
use Laravel\Mcp\Server\Methods\ListPrompts;
1616
use Laravel\Mcp\Server\Methods\ListResources;
17+
use Laravel\Mcp\Server\Methods\ListResourceTemplates;
1718
use Laravel\Mcp\Server\Methods\ListTools;
1819
use Laravel\Mcp\Server\Methods\Ping;
1920
use Laravel\Mcp\Server\Methods\ReadResource;
@@ -93,6 +94,7 @@ abstract class Server
9394
'tools/call' => CallTool::class,
9495
'resources/list' => ListResources::class,
9596
'resources/read' => ReadResource::class,
97+
'resources/templates/list' => ListResourceTemplates::class,
9698
'prompts/list' => ListPrompts::class,
9799
'prompts/get' => GetPrompt::class,
98100
'ping' => Ping::class,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Contracts;
6+
7+
use Laravel\Mcp\Support\UriTemplate;
8+
9+
interface HasUriTemplate
10+
{
11+
/**
12+
* Get the URI pattern for the resource template.
13+
*/
14+
public function uriTemplate(): UriTemplate;
15+
}

src/Server/McpServiceProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ protected function registerPublishing(): void
4747
$this->publishes([
4848
__DIR__.'/../../stubs/prompt.stub' => base_path('stubs/prompt.stub'),
4949
__DIR__.'/../../stubs/resource.stub' => base_path('stubs/resource.stub'),
50+
__DIR__.'/../../stubs/resource-template.stub' => base_path('stubs/resource-template.stub'),
5051
__DIR__.'/../../stubs/server.stub' => base_path('stubs/server.stub'),
5152
__DIR__.'/../../stubs/tool.stub' => base_path('stubs/tool.stub'),
5253
], 'mcp-stubs');
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Methods;
6+
7+
use Laravel\Mcp\Server\Contracts\Method;
8+
use Laravel\Mcp\Server\Pagination\CursorPaginator;
9+
use Laravel\Mcp\Server\ServerContext;
10+
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
11+
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
12+
13+
class ListResourceTemplates implements Method
14+
{
15+
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
16+
{
17+
$paginator = new CursorPaginator(
18+
items: $context->resourceTemplates(),
19+
perPage: $context->perPage($request->get('per_page')),
20+
cursor: $request->cursor(),
21+
);
22+
23+
return JsonRpcResponse::result($request->id, $paginator->paginate('resourceTemplates'));
24+
}
25+
}

src/Server/Methods/ReadResource.php

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66

77
use Generator;
88
use Illuminate\Container\Container;
9+
use Illuminate\Contracts\Container\BindingResolutionException;
910
use Illuminate\Validation\ValidationException;
11+
use Laravel\Mcp\Request;
1012
use Laravel\Mcp\Response;
1113
use Laravel\Mcp\ResponseFactory;
14+
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
1215
use Laravel\Mcp\Server\Contracts\Method;
1316
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
1417
use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses;
@@ -26,6 +29,7 @@ class ReadResource implements Method
2629
* @return Generator<JsonRpcResponse>|JsonRpcResponse
2730
*
2831
* @throws JsonRpcException
32+
* @throws BindingResolutionException
2933
*/
3034
public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse
3135
{
@@ -37,31 +41,60 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat
3741
);
3842
}
3943

40-
$resource = $context->resources()
41-
->first(
42-
fn (Resource $resource): bool => $resource->uri() === $request->get('uri'),
43-
fn () => throw new JsonRpcException(
44-
"Resource [{$request->get('uri')}] not found.",
45-
-32002,
46-
$request->id,
47-
));
44+
$uri = $request->get('uri');
45+
46+
/** @var Resource|null $resource */
47+
$resource = $context->resources()->first(fn (Resource $resource): bool => $resource->uri() === $uri) ??
48+
$context->resourceTemplates()->first(fn (HasUriTemplate $template): bool => ! is_null($template->uriTemplate()->match($uri)));
49+
50+
if (is_null($resource)) {
51+
throw new JsonRpcException("Resource [{$uri}] not found.", -32002, $request->id);
52+
}
4853

4954
try {
50-
// @phpstan-ignore-next-line
51-
$response = Container::getInstance()->call([$resource, 'handle']);
55+
$response = $this->invokeResource($resource, $uri);
5256
} catch (ValidationException $validationException) {
5357
$response = Response::error('Invalid params: '.ValidationMessages::from($validationException));
5458
}
5559

5660
return is_iterable($response)
57-
? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource))
58-
: $this->toJsonRpcResponse($request, $response, $this->serializable($resource));
61+
? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource, $uri))
62+
: $this->toJsonRpcResponse($request, $response, $this->serializable($resource, $uri));
63+
}
64+
65+
/**
66+
* @throws BindingResolutionException
67+
* @throws ValidationException
68+
*/
69+
protected function invokeResource(Resource $resource, string $uri): mixed
70+
{
71+
$container = Container::getInstance();
72+
73+
$request = $container->make(Request::class);
74+
$request->setUri($uri);
75+
76+
if ($resource instanceof HasUriTemplate) {
77+
$variables = $resource->uriTemplate()->match($uri) ?? [];
78+
$request->merge($variables);
79+
}
80+
81+
$container->instance(Request::class, $request);
82+
83+
try {
84+
// @phpstan-ignore-next-line
85+
return $container->call([$resource, 'handle']);
86+
} finally {
87+
$container->forgetInstance(Request::class);
88+
}
5989
}
6090

61-
protected function serializable(Resource $resource): callable
91+
protected function serializable(Resource $resource, string $uri): callable
6292
{
6393
return fn (ResponseFactory $factory): array => $factory->mergeMeta([
64-
'contents' => $factory->responses()->map(fn (Response $response): array => $response->content()->toResource($resource))->all(),
94+
'contents' => $factory->responses()->map(fn (Response $response): array => [
95+
...$response->content()->toResource($resource),
96+
'uri' => $uri,
97+
])->all(),
6598
]);
6699
}
67100
}

src/Server/Resource.php

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Illuminate\Support\Str;
88
use Laravel\Mcp\Server\Annotations\Annotation;
99
use Laravel\Mcp\Server\Concerns\HasAnnotations;
10+
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
1011

1112
abstract class Resource extends Primitive
1213
{
@@ -18,9 +19,11 @@ abstract class Resource extends Primitive
1819

1920
public function uri(): string
2021
{
21-
return $this->uri !== ''
22-
? $this->uri
23-
: 'file://resources/'.Str::kebab(class_basename($this));
22+
if ($this instanceof HasUriTemplate) {
23+
return (string) $this->uriTemplate();
24+
}
25+
26+
return $this->uri !== '' ? $this->uri : 'file://resources/'.Str::kebab(class_basename($this));
2427
}
2528

2629
public function mimeType(): string
@@ -43,7 +46,8 @@ public function toMethodCall(): array
4346
* name: string,
4447
* title: string,
4548
* description: string,
46-
* uri: string,
49+
* uri?: string,
50+
* uriTemplate?: string,
4751
* mimeType: string,
4852
* _meta?: array<string, mixed>
4953
* }
@@ -56,14 +60,19 @@ public function toArray(): array
5660
'name' => $this->name(),
5761
'title' => $this->title(),
5862
'description' => $this->description(),
59-
'uri' => $this->uri(),
6063
'mimeType' => $this->mimeType(),
6164
];
6265

6366
if ($annotations !== []) {
6467
$data['annotations'] = $annotations;
6568
}
6669

70+
if ($this instanceof HasUriTemplate) {
71+
$data['uriTemplate'] = (string) $this->uriTemplate();
72+
} else {
73+
$data['uri'] = $this->uri();
74+
}
75+
6776
// @phpstan-ignore return.type
6877
return $this->mergeMeta($data);
6978
}

src/Server/ServerContext.php

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Illuminate\Container\Container;
88
use Illuminate\Support\Collection;
9+
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
910

1011
class ServerContext
1112
{
@@ -36,38 +37,68 @@ public function __construct(
3637
*/
3738
public function tools(): Collection
3839
{
39-
return collect($this->tools)->map(fn (Tool|string $toolClass) => is_string($toolClass)
40-
? Container::getInstance()->make($toolClass)
41-
: $toolClass
42-
)->filter(fn (Tool $tool): bool => $tool->eligibleForRegistration());
40+
/** @var Collection<int,Tool> $tools */
41+
$tools = collect($this->tools);
42+
43+
return $this->resolvePrimitives($tools);
4344
}
4445

4546
/**
4647
* @return Collection<int, Resource>
4748
*/
4849
public function resources(): Collection
4950
{
50-
return collect($this->resources)->map(
51-
fn (Resource|string $resourceClass) => is_string($resourceClass)
52-
? Container::getInstance()->make($resourceClass)
53-
: $resourceClass
54-
)->filter(fn (Resource $resource): bool => $resource->eligibleForRegistration());
51+
/** @var Collection<int,Resource> $resourceTemplates */
52+
$resourceTemplates = collect($this->resources)
53+
->filter(fn (Resource|string $resource): bool => ! $this->isResourceTemplate($resource));
54+
55+
return $this->resolvePrimitives($resourceTemplates);
56+
}
57+
58+
/**
59+
* @return Collection<int, HasUriTemplate&Resource>
60+
*/
61+
public function resourceTemplates(): Collection
62+
{
63+
/** @var Collection<int,HasUriTemplate&Resource> $resourceTemplates */
64+
$resourceTemplates = collect($this->resources)
65+
->filter(fn (Resource|string $resource): bool => $this->isResourceTemplate($resource));
66+
67+
return $this->resolvePrimitives($resourceTemplates);
5568
}
5669

5770
/**
5871
* @return Collection<int, Prompt>
5972
*/
6073
public function prompts(): Collection
6174
{
62-
return collect($this->prompts)->map(
63-
fn ($promptClass) => is_string($promptClass)
64-
? Container::getInstance()->make($promptClass)
65-
: $promptClass
66-
)->filter(fn (Prompt $prompt): bool => $prompt->eligibleForRegistration());
75+
/** @var Collection<int,Prompt> $prompts */
76+
$prompts = collect($this->prompts);
77+
78+
return $this->resolvePrimitives($prompts);
6779
}
6880

6981
public function perPage(?int $requestedPerPage = null): int
7082
{
7183
return min($requestedPerPage ?? $this->defaultPaginationLength, $this->maxPaginationLength);
7284
}
85+
86+
/**
87+
* @template T of Primitive
88+
*
89+
* @param Collection<int, T|string> $primitive
90+
* @return Collection<int, T>
91+
*/
92+
private function resolvePrimitives(Collection $primitive): Collection
93+
{
94+
return $primitive->map(fn (Primitive|string $primitiveClass) => is_string($primitiveClass)
95+
? Container::getInstance()->make($primitiveClass)
96+
: $primitiveClass)
97+
->filter(fn (Primitive $primitive): bool => $primitive->eligibleForRegistration());
98+
}
99+
100+
private function isResourceTemplate(Resource|string $resource): bool
101+
{
102+
return $resource instanceof HasUriTemplate || (is_string($resource) && is_subclass_of($resource, HasUriTemplate::class));
103+
}
73104
}

0 commit comments

Comments
 (0)