diff --git a/src/Server/Primitive.php b/src/Server/Primitive.php index 7a3adb1..97c08e5 100644 --- a/src/Server/Primitive.php +++ b/src/Server/Primitive.php @@ -19,6 +19,11 @@ abstract class Primitive implements Arrayable protected string $description = ''; + /** + * @var array + */ + protected array $meta = []; + public function name(): string { return $this->name === '' @@ -40,6 +45,14 @@ public function description(): string : $this->description; } + /** + * @return array + */ + public function meta(): array + { + return $this->meta; + } + public function eligibleForRegistration(): bool { if (method_exists($this, 'shouldRegister')) { diff --git a/src/Server/Tool.php b/src/Server/Tool.php index a9568af..717591d 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -6,6 +6,7 @@ use Illuminate\JsonSchema\JsonSchema; use Laravel\Mcp\Server\Contracts\Tools\Annotation; +use Laravel\Mcp\Support\SecurityScheme; use ReflectionAttribute; use ReflectionClass; @@ -19,6 +20,14 @@ public function schema(JsonSchema $schema): array return []; } + /** + * @return array + */ + public function securitySchemes(SecurityScheme $scheme): array + { + return []; + } + /** * @return array */ @@ -51,7 +60,9 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, - * annotations?: array|object + * annotations?: array|object, + * securitySchemes?: array, + * _meta?: array * } */ public function toArray(): array @@ -63,12 +74,17 @@ public function toArray(): array $schema['properties'] ??= (object) []; - return [ + return array_merge([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), 'inputSchema' => $schema, 'annotations' => $annotations === [] ? (object) [] : $annotations, - ]; + ], array_filter([ + 'securitySchemes' => SecurityScheme::make( + $this->securitySchemes(...), + ), + '_meta' => filled($this->meta()) ? $this->meta() : null, + ], filled(...))); } } diff --git a/src/Support/SecurityScheme.php b/src/Support/SecurityScheme.php new file mode 100644 index 0000000..00f61e6 --- /dev/null +++ b/src/Support/SecurityScheme.php @@ -0,0 +1,152 @@ + */ + protected array $scopes = []; + + /** @var array */ + protected array $additionalData = []; + + private function __construct(string $type = '') + { + if ($type !== '') { + $this->type = $type; + } + } + + protected function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + /** + * @param (Closure(SecurityScheme): array>)|array> $schemes + * @return array> + */ + public static function make(Closure|array $schemes = []): array + { + if ($schemes instanceof Closure) { + $schemes = $schemes(new self); + } + + $result = collect($schemes)->map( + fn ($scheme): array => $scheme instanceof self ? $scheme->toArray() : $scheme + ); + + return $result->toArray(); + } + + /** + * @param string|array ...$scopes + */ + public function scopes(string|array ...$scopes): self + { + $this->scopes = collect($scopes) + ->flatten() + ->toArray(); + + return $this; + } + + public function with(string $key, mixed $value): self + { + $this->additionalData[$key] = $value; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + $scheme = array_merge(['type' => $this->type], $this->additionalData); + + if ($this->scopes !== []) { + $scheme['scopes'] = $this->scopes; + } + + return $scheme; + } + + public static function type(string $type): self + { + return new self($type); + } + + /** + * @return array + */ + public static function noauth(): array + { + return ['type' => 'noauth']; + } + + /** + * @param string|array ...$scopes + */ + public static function oauth2(string|array ...$scopes): self + { + $instance = self::type('oauth2'); + + if ($scopes !== []) { + $instance->scopes(...$scopes); + } + + return $instance; + } + + /** + * @return array + */ + public static function apiKey(string $name = 'api_key', string $in = 'header'): array + { + return [ + 'type' => 'apiKey', + 'name' => $name, + 'in' => $in, + ]; + } + + /** + * @return array + */ + public static function bearer(string $format = 'JWT'): array + { + return [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => $format, + ]; + } + + /** + * @return array + */ + public static function basic(): array + { + return [ + 'type' => 'http', + 'scheme' => 'basic', + ]; + } + + /** + * @return array + */ + public function __invoke(): array + { + return $this->toArray(); + } +} diff --git a/tests/Unit/Support/SecuritySchemeTest.php b/tests/Unit/Support/SecuritySchemeTest.php new file mode 100644 index 0000000..c37ace9 --- /dev/null +++ b/tests/Unit/Support/SecuritySchemeTest.php @@ -0,0 +1,109 @@ +with('flows', [ + 'authorizationCode' => [ + 'authorizationUrl' => 'https://example.com/oauth/authorize', + 'tokenUrl' => 'https://example.com/oauth/token', + ], + ]); + + expect($scheme->toArray())->toBe([ + 'type' => 'oauth2', + 'flows' => [ + 'authorizationCode' => [ + 'authorizationUrl' => 'https://example.com/oauth/authorize', + 'tokenUrl' => 'https://example.com/oauth/token', + ], + ], + 'scopes' => ['read', 'write'], + ]); +}); + +it('can set scopes', function (): void { + $scheme = SecurityScheme::oauth2() + ->scopes('read', 'write', 'delete'); + + expect($scheme->toArray()['scopes'])->toBe(['read', 'write', 'delete']); +}); + +it('can set an oauth tyoe', function (): void { + $scheme = SecurityScheme::oauth2(); + + expect($scheme->toArray()['type'])->toBe('oauth2'); +}); + +it('can set a noauth type', function (): void { + $scheme = SecurityScheme::noauth(); + + expect($scheme)->toBe([ + 'type' => 'noauth', + ]); +}); + +it('can set a type', function (): void { + $scheme = SecurityScheme::type('apiKey'); + + expect($scheme->toArray()['type'])->toBe('apiKey'); +}); + +it('can set an apiKey auth', function (): void { + $scheme = SecurityScheme::apiKey('X-API-KEY', 'header'); + + expect($scheme)->toBe([ + 'type' => 'apiKey', + 'name' => 'X-API-KEY', + 'in' => 'header', + ]); +}); + +it('can set a bearer auth', function (): void { + $scheme = SecurityScheme::bearer('JWT'); + + expect($scheme)->toBe([ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ]); +}); + +it('can set a basic auth', function (): void { + $scheme = SecurityScheme::basic(); + + expect($scheme)->toBe([ + 'type' => 'http', + 'scheme' => 'basic', + ]); +}); + +it('can make a set of schemes', function (): void { + $schemes = SecurityScheme::make([ + SecurityScheme::basic(), + SecurityScheme::bearer('JWT'), + [ + 'type' => 'apiKey', + 'name' => 'X-API-KEY', + 'in' => 'header', + ], + ]); + + expect($schemes)->toBe([ + [ + 'type' => 'http', + 'scheme' => 'basic', + ], + [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ], + [ + 'type' => 'apiKey', + 'name' => 'X-API-KEY', + 'in' => 'header', + ], + ]); +}); diff --git a/tests/Unit/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index c30c158..12e57b5 100644 --- a/tests/Unit/Tools/ToolTest.php +++ b/tests/Unit/Tools/ToolTest.php @@ -6,6 +6,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; use Laravel\Mcp\Server\Tools\ToolResult; +use Laravel\Mcp\Support\SecurityScheme; test('the default name is in kebab case', function (): void { $tool = new AnotherComplexToolName; @@ -94,6 +95,23 @@ ->and($array['inputSchema']['required'])->toEqual(['message']); }); +it('returns no meta by default', function (): void { + $tool = new TestTool; + expect($tool->meta())->toEqual([]); +}); + +it('can have custom meta', function (): void { + $tool = new CustomMetaTool; + expect($tool->toArray()['_meta'])->toEqual(['key' => 'value']); +}); + +it('can set security schemes', function (): void { + $tool = new SecuritySchemesTool; + expect($tool->toArray()['securitySchemes'])->toEqual([ + ['type' => 'oauth2', 'scopes' => ['read', 'write']], + ]); +}); + class TestTool extends Tool { public function description(): string @@ -155,3 +173,20 @@ public function schema(\Illuminate\JsonSchema\JsonSchema $schema): array ]; } } + +class CustomMetaTool extends TestTool +{ + protected array $meta = [ + 'key' => 'value', + ]; +} + +class SecuritySchemesTool extends TestTool +{ + public function securitySchemes(SecurityScheme $scheme): array + { + return [ + $scheme::oauth2('read', 'write'), + ]; + } +}