Skip to content

Commit e1acc1f

Browse files
authored
Add Annotation Support on Resources (#111)
* Add Annotation Support on Resources * Fix Test * Change Namespace * Change Namespace * Change Namespace * Refactor * Refactor * Refactor
1 parent 0b2f777 commit e1acc1f

File tree

19 files changed

+749
-37
lines changed

19 files changed

+749
-37
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Annotations;
6+
7+
use Laravel\Mcp\Server\Contracts\Annotation as AnnotationContract;
8+
9+
abstract class Annotation implements AnnotationContract
10+
{
11+
//
12+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Annotations;
6+
7+
use Attribute;
8+
use Illuminate\Support\Arr;
9+
use InvalidArgumentException;
10+
use Laravel\Mcp\Enums\Role;
11+
12+
#[Attribute(Attribute::TARGET_CLASS)]
13+
class Audience extends Annotation
14+
{
15+
/** @var array<int,string> */
16+
public array $value;
17+
18+
/**
19+
* @param Role|array<int, Role> $roles
20+
*/
21+
public function __construct(Role|array $roles)
22+
{
23+
$roles = Arr::wrap($roles);
24+
25+
foreach ($roles as $role) {
26+
if (! $role instanceof Role) {
27+
throw new InvalidArgumentException(
28+
'All values of '.Audience::class.' attributes must be instances of '.Role::class
29+
);
30+
}
31+
}
32+
33+
$this->value = array_map(fn (Role $role) => $role->value, $roles);
34+
}
35+
36+
public function key(): string
37+
{
38+
return 'audience';
39+
}
40+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Annotations;
6+
7+
use Attribute;
8+
use DateTimeImmutable;
9+
use Exception;
10+
use InvalidArgumentException;
11+
12+
#[Attribute(Attribute::TARGET_CLASS)]
13+
class LastModified extends Annotation
14+
{
15+
public function __construct(public string $value)
16+
{
17+
try {
18+
new DateTimeImmutable($value);
19+
} catch (Exception $exception) {
20+
throw new InvalidArgumentException("LastModified must be a valid ISO 8601 timestamp, got '{$value}'", $exception->getCode(), previous: $exception);
21+
}
22+
}
23+
24+
public function key(): string
25+
{
26+
return 'lastModified';
27+
}
28+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Annotations;
6+
7+
use Attribute;
8+
use InvalidArgumentException;
9+
10+
#[Attribute(Attribute::TARGET_CLASS)]
11+
class Priority extends Annotation
12+
{
13+
public function __construct(public float $value)
14+
{
15+
if ($value < 0.0 || $value > 1.0) {
16+
throw new InvalidArgumentException(
17+
"Priority must be between 0.0 and 1.0, got {$value}"
18+
);
19+
}
20+
}
21+
22+
public function key(): string
23+
{
24+
return 'priority';
25+
}
26+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Concerns;
6+
7+
use InvalidArgumentException;
8+
use Laravel\Mcp\Server\Contracts\Annotation as AnnotationContract;
9+
use ReflectionAttribute;
10+
use ReflectionClass;
11+
12+
trait HasAnnotations
13+
{
14+
/**
15+
* @return array<string, mixed>
16+
*/
17+
public function annotations(): array
18+
{
19+
$reflection = new ReflectionClass($this);
20+
21+
/** @var \Illuminate\Support\Collection<int, AnnotationContract> $annotations */
22+
$annotations = collect($reflection->getAttributes())
23+
->map(fn (ReflectionAttribute $attributeReflection): object => $attributeReflection->newInstance())
24+
->filter(fn (object $attribute): bool => $attribute instanceof AnnotationContract)
25+
->values();
26+
27+
// @phpstan-ignore argument.templateType
28+
return $annotations
29+
->each(function (AnnotationContract $attribute): void {
30+
$this->validateAnnotationUsage($attribute);
31+
})
32+
->mapWithKeys(fn (AnnotationContract $attribute): array => [
33+
$attribute->key() => $attribute->value, // @phpstan-ignore property.notFound
34+
])
35+
->all();
36+
}
37+
38+
private function validateAnnotationUsage(AnnotationContract $attribute): void
39+
{
40+
$allowedAnnotations = $this->allowedAnnotations();
41+
42+
foreach ($allowedAnnotations as $allowedAnnotationClass) {
43+
if ($attribute instanceof $allowedAnnotationClass) {
44+
return;
45+
}
46+
}
47+
48+
$allowedClasses = empty($allowedAnnotations)
49+
? 'none'
50+
: implode(', ', $allowedAnnotations);
51+
52+
throw new InvalidArgumentException(
53+
sprintf(
54+
'Annotation [%s] cannot be used on [%s]. Allowed annotation types: [%s]',
55+
$attribute::class,
56+
$this::class,
57+
$allowedClasses
58+
)
59+
);
60+
}
61+
62+
/**
63+
* @return array<int, class-string>
64+
*/
65+
protected function allowedAnnotations(): array
66+
{
67+
return [];
68+
}
69+
}

src/Server/Contracts/Tools/Annotation.php renamed to src/Server/Contracts/Annotation.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
declare(strict_types=1);
44

5-
namespace Laravel\Mcp\Server\Contracts\Tools;
5+
namespace Laravel\Mcp\Server\Contracts;
66

77
interface Annotation
88
{

src/Server/Resource.php

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
namespace Laravel\Mcp\Server;
66

77
use Illuminate\Support\Str;
8+
use Laravel\Mcp\Server\Annotations\Annotation;
9+
use Laravel\Mcp\Server\Concerns\HasAnnotations;
810

911
abstract class Resource extends Primitive
1012
{
13+
use HasAnnotations;
14+
1115
protected string $uri = '';
1216

1317
protected string $mimeType = '';
@@ -46,13 +50,31 @@ public function toMethodCall(): array
4650
*/
4751
public function toArray(): array
4852
{
49-
// @phpstan-ignore return.type
50-
return $this->mergeMeta([
53+
$annotations = $this->annotations();
54+
55+
$data = [
5156
'name' => $this->name(),
5257
'title' => $this->title(),
5358
'description' => $this->description(),
5459
'uri' => $this->uri(),
5560
'mimeType' => $this->mimeType(),
56-
]);
61+
];
62+
63+
if ($annotations !== []) {
64+
$data['annotations'] = $annotations;
65+
}
66+
67+
// @phpstan-ignore return.type
68+
return $this->mergeMeta($data);
69+
}
70+
71+
/**
72+
* @return array<int, class-string>
73+
*/
74+
protected function allowedAnnotations(): array
75+
{
76+
return [
77+
Annotation::class,
78+
];
5779
}
5880
}

src/Server/Tool.php

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
namespace Laravel\Mcp\Server;
66

77
use Illuminate\JsonSchema\JsonSchema;
8-
use Laravel\Mcp\Server\Contracts\Tools\Annotation;
9-
use ReflectionAttribute;
10-
use ReflectionClass;
8+
use Laravel\Mcp\Server\Concerns\HasAnnotations;
9+
use Laravel\Mcp\Server\Tools\Annotations\ToolAnnotation;
1110

1211
abstract class Tool extends Primitive
1312
{
13+
use HasAnnotations;
14+
1415
/**
1516
* @return array<string, mixed>
1617
*/
@@ -19,22 +20,6 @@ public function schema(JsonSchema $schema): array
1920
return [];
2021
}
2122

22-
/**
23-
* @return array<string, mixed>
24-
*/
25-
public function annotations(): array
26-
{
27-
$reflection = new ReflectionClass($this);
28-
29-
// @phpstan-ignore-next-line
30-
return collect($reflection->getAttributes())
31-
->map(fn (ReflectionAttribute $attributeReflection): object => $attributeReflection->newInstance())
32-
->filter(fn (object $attribute): bool => $attribute instanceof Annotation)
33-
// @phpstan-ignore-next-line
34-
->mapWithKeys(fn (Annotation $attribute): array => [$attribute->key() => $attribute->value])
35-
->all();
36-
}
37-
3823
/**
3924
* @return array<string, mixed>
4025
*/
@@ -73,6 +58,15 @@ public function toArray(): array
7358
'inputSchema' => $schema,
7459
'annotations' => $annotations === [] ? (object) [] : $annotations,
7560
]);
61+
}
7662

63+
/**
64+
* @return array<int, class-string>
65+
*/
66+
protected function allowedAnnotations(): array
67+
{
68+
return [
69+
ToolAnnotation::class,
70+
];
7771
}
7872
}

src/Server/Tools/Annotations/IsDestructive.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
namespace Laravel\Mcp\Server\Tools\Annotations;
66

77
use Attribute;
8-
use Laravel\Mcp\Server\Contracts\Tools\Annotation;
98

109
#[Attribute(Attribute::TARGET_CLASS)]
11-
class IsDestructive implements Annotation
10+
class IsDestructive extends ToolAnnotation
1211
{
1312
public function __construct(public bool $value = true)
1413
{

src/Server/Tools/Annotations/IsIdempotent.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
namespace Laravel\Mcp\Server\Tools\Annotations;
66

77
use Attribute;
8-
use Laravel\Mcp\Server\Contracts\Tools\Annotation;
98

109
#[Attribute(Attribute::TARGET_CLASS)]
11-
class IsIdempotent implements Annotation
10+
class IsIdempotent extends ToolAnnotation
1211
{
1312
public function __construct(public bool $value = true)
1413
{

0 commit comments

Comments
 (0)