Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/Server/Annotations/Annotation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Annotations;

use Laravel\Mcp\Server\Contracts\Annotation as AnnotationContract;

abstract class Annotation implements AnnotationContract
{
//
}
40 changes: 40 additions & 0 deletions src/Server/Annotations/Audience.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Annotations;

use Attribute;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use Laravel\Mcp\Enums\Role;

#[Attribute(Attribute::TARGET_CLASS)]
class Audience extends Annotation
{
/** @var array<int,string> */
public array $value;

/**
* @param Role|array<int, Role> $roles
*/
public function __construct(Role|array $roles)
{
$roles = Arr::wrap($roles);

foreach ($roles as $role) {
if (! $role instanceof Role) {
throw new InvalidArgumentException(
'All values of '.Audience::class.' attributes must be instances of '.Role::class
);
}
}

$this->value = array_map(fn (Role $role) => $role->value, $roles);
}

public function key(): string
{
return 'audience';
}
}
28 changes: 28 additions & 0 deletions src/Server/Annotations/LastModified.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Annotations;

use Attribute;
use DateTimeImmutable;
use Exception;
use InvalidArgumentException;

#[Attribute(Attribute::TARGET_CLASS)]
class LastModified extends Annotation
{
public function __construct(public string $value)
{
try {
new DateTimeImmutable($value);
} catch (Exception $exception) {
throw new InvalidArgumentException("LastModified must be a valid ISO 8601 timestamp, got '{$value}'", $exception->getCode(), previous: $exception);
}
}

public function key(): string
{
return 'lastModified';
}
}
26 changes: 26 additions & 0 deletions src/Server/Annotations/Priority.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Annotations;

use Attribute;
use InvalidArgumentException;

#[Attribute(Attribute::TARGET_CLASS)]
class Priority extends Annotation
{
public function __construct(public float $value)
{
if ($value < 0.0 || $value > 1.0) {
throw new InvalidArgumentException(
"Priority must be between 0.0 and 1.0, got {$value}"
);
}
}

public function key(): string
{
return 'priority';
}
}
69 changes: 69 additions & 0 deletions src/Server/Concerns/HasAnnotations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Concerns;

use InvalidArgumentException;
use Laravel\Mcp\Server\Contracts\Annotation as AnnotationContract;
use ReflectionAttribute;
use ReflectionClass;

trait HasAnnotations
{
/**
* @return array<string, mixed>
*/
public function annotations(): array
{
$reflection = new ReflectionClass($this);

/** @var \Illuminate\Support\Collection<int, AnnotationContract> $annotations */
$annotations = collect($reflection->getAttributes())
->map(fn (ReflectionAttribute $attributeReflection): object => $attributeReflection->newInstance())
->filter(fn (object $attribute): bool => $attribute instanceof AnnotationContract)
->values();

// @phpstan-ignore argument.templateType
return $annotations
->each(function (AnnotationContract $attribute): void {
$this->validateAnnotationUsage($attribute);
})
->mapWithKeys(fn (AnnotationContract $attribute): array => [
$attribute->key() => $attribute->value, // @phpstan-ignore property.notFound
])
->all();
}

private function validateAnnotationUsage(AnnotationContract $attribute): void
{
$allowedAnnotations = $this->allowedAnnotations();

foreach ($allowedAnnotations as $allowedAnnotationClass) {
if ($attribute instanceof $allowedAnnotationClass) {
return;
}
}

$allowedClasses = empty($allowedAnnotations)
? 'none'
: implode(', ', $allowedAnnotations);

throw new InvalidArgumentException(
sprintf(
'Annotation [%s] cannot be used on [%s]. Allowed annotation types: [%s]',
$attribute::class,
$this::class,
$allowedClasses
)
);
}

/**
* @return array<int, class-string>
*/
protected function allowedAnnotations(): array
{
return [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace Laravel\Mcp\Server\Contracts\Tools;
namespace Laravel\Mcp\Server\Contracts;

interface Annotation
{
Expand Down
28 changes: 25 additions & 3 deletions src/Server/Resource.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
namespace Laravel\Mcp\Server;

use Illuminate\Support\Str;
use Laravel\Mcp\Server\Annotations\Annotation;
use Laravel\Mcp\Server\Concerns\HasAnnotations;

abstract class Resource extends Primitive
{
use HasAnnotations;

protected string $uri = '';

protected string $mimeType = '';
Expand Down Expand Up @@ -46,13 +50,31 @@ public function toMethodCall(): array
*/
public function toArray(): array
{
// @phpstan-ignore return.type
return $this->mergeMeta([
$annotations = $this->annotations();

$data = [
'name' => $this->name(),
'title' => $this->title(),
'description' => $this->description(),
'uri' => $this->uri(),
'mimeType' => $this->mimeType(),
]);
];

if ($annotations !== []) {
$data['annotations'] = $annotations;
}

// @phpstan-ignore return.type
return $this->mergeMeta($data);
}

/**
* @return array<int, class-string>
*/
protected function allowedAnnotations(): array
{
return [
Annotation::class,
];
}
}
32 changes: 13 additions & 19 deletions src/Server/Tool.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
namespace Laravel\Mcp\Server;

use Illuminate\JsonSchema\JsonSchema;
use Laravel\Mcp\Server\Contracts\Tools\Annotation;
use ReflectionAttribute;
use ReflectionClass;
use Laravel\Mcp\Server\Concerns\HasAnnotations;
use Laravel\Mcp\Server\Tools\Annotations\ToolAnnotation;

abstract class Tool extends Primitive
{
use HasAnnotations;

/**
* @return array<string, mixed>
*/
Expand All @@ -19,22 +20,6 @@ public function schema(JsonSchema $schema): array
return [];
}

/**
* @return array<string, mixed>
*/
public function annotations(): array
{
$reflection = new ReflectionClass($this);

// @phpstan-ignore-next-line
return collect($reflection->getAttributes())
->map(fn (ReflectionAttribute $attributeReflection): object => $attributeReflection->newInstance())
->filter(fn (object $attribute): bool => $attribute instanceof Annotation)
// @phpstan-ignore-next-line
->mapWithKeys(fn (Annotation $attribute): array => [$attribute->key() => $attribute->value])
->all();
}

/**
* @return array<string, mixed>
*/
Expand Down Expand Up @@ -73,6 +58,15 @@ public function toArray(): array
'inputSchema' => $schema,
'annotations' => $annotations === [] ? (object) [] : $annotations,
]);
}

/**
* @return array<int, class-string>
*/
protected function allowedAnnotations(): array
{
return [
ToolAnnotation::class,
];
}
}
3 changes: 1 addition & 2 deletions src/Server/Tools/Annotations/IsDestructive.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
namespace Laravel\Mcp\Server\Tools\Annotations;

use Attribute;
use Laravel\Mcp\Server\Contracts\Tools\Annotation;

#[Attribute(Attribute::TARGET_CLASS)]
class IsDestructive implements Annotation
class IsDestructive extends ToolAnnotation
{
public function __construct(public bool $value = true)
{
Expand Down
3 changes: 1 addition & 2 deletions src/Server/Tools/Annotations/IsIdempotent.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
namespace Laravel\Mcp\Server\Tools\Annotations;

use Attribute;
use Laravel\Mcp\Server\Contracts\Tools\Annotation;

#[Attribute(Attribute::TARGET_CLASS)]
class IsIdempotent implements Annotation
class IsIdempotent extends ToolAnnotation
{
public function __construct(public bool $value = true)
{
Expand Down
3 changes: 1 addition & 2 deletions src/Server/Tools/Annotations/IsOpenWorld.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
namespace Laravel\Mcp\Server\Tools\Annotations;

use Attribute;
use Laravel\Mcp\Server\Contracts\Tools\Annotation;

#[Attribute(Attribute::TARGET_CLASS)]
class IsOpenWorld implements Annotation
class IsOpenWorld extends ToolAnnotation
{
public function __construct(public bool $value = true)
{
Expand Down
3 changes: 1 addition & 2 deletions src/Server/Tools/Annotations/IsReadOnly.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
namespace Laravel\Mcp\Server\Tools\Annotations;

use Attribute;
use Laravel\Mcp\Server\Contracts\Tools\Annotation;

#[Attribute(Attribute::TARGET_CLASS)]
class IsReadOnly implements Annotation
class IsReadOnly extends ToolAnnotation
{
public function __construct(public bool $value = true)
{
Expand Down
12 changes: 12 additions & 0 deletions src/Server/Tools/Annotations/ToolAnnotation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Tools\Annotations;

use Laravel\Mcp\Server\Contracts\Annotation;

abstract class ToolAnnotation implements Annotation
{
//
}
8 changes: 6 additions & 2 deletions tests/ArchTest.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php

use Illuminate\Console\Command;
use Laravel\Mcp\Server\Contracts\Annotation;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Contracts\Tools\Annotation;
use Laravel\Mcp\Server\Testing\TestResponse;
use Symfony\Component\Console\Attribute\AsCommand;

Expand All @@ -21,8 +21,12 @@
->expect('Laravel\Mcp\Server\Tools\Annotations')
->toOnlyImplement(Annotation::class);

arch('resource annotations implement annotation interface')
->expect('Laravel\Mcp\Server\Annotations')
->toOnlyImplement(Annotation::class);

arch('contracts are interfaces')
->expect('Laravel\Mcp\Server\Contracts\*')
->expect('Laravel\Mcp\Server\Contracts')
->toBeInterfaces();

arch('exceptions extend')
Expand Down
2 changes: 2 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Tests;

use Laravel\Mcp\Server\Contracts\Resources\Content;
Expand Down
Loading