From 4baecb7f519310ca85d1f37f1162a4feb607519a Mon Sep 17 00:00:00 2001 From: zacksmash Date: Thu, 6 Nov 2025 07:35:04 -0700 Subject: [PATCH 1/4] Add meta and security scheme to tools --- src/Server/Primitive.php | 13 ++ src/Server/Tool.php | 18 ++- src/Support/SecurityScheme.php | 152 ++++++++++++++++++++++ tests/Unit/Support/SecuritySchemeTest.php | 109 ++++++++++++++++ tests/Unit/Tools/ToolTest.php | 35 +++++ 5 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/Support/SecurityScheme.php create mode 100644 tests/Unit/Support/SecuritySchemeTest.php diff --git a/src/Server/Primitive.php b/src/Server/Primitive.php index 7a3adb12..97c08e5c 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 a9568aff..b5d827d8 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 */ @@ -63,12 +72,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 00000000..00f61e66 --- /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 00000000..c37ace9e --- /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 c30c158d..12e57b5f 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'), + ]; + } +} From 79e0f27dd2313e4d1c383401a9fe7a1b18c613ea Mon Sep 17 00:00:00 2001 From: zacksmash Date: Thu, 6 Nov 2025 07:57:50 -0700 Subject: [PATCH 2/4] Fix phpstan definition --- src/Server/Tool.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Server/Tool.php b/src/Server/Tool.php index b5d827d8..475b1d06 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -61,6 +61,8 @@ public function toMethodCall(): array * description?: string|null, * inputSchema?: array, * annotations?: array|object + * securitySchemes?: array, + * _meta?: array * } */ public function toArray(): array From 6c71ed52a4d7d4008a92a7dc64fdf267e9a01812 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Thu, 6 Nov 2025 08:01:08 -0700 Subject: [PATCH 3/4] =?UTF-8?q?WIP=20=F0=9F=9A=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Server/Tool.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Tool.php b/src/Server/Tool.php index 475b1d06..717591de 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -60,7 +60,7 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, - * annotations?: array|object + * annotations?: array|object, * securitySchemes?: array, * _meta?: array * } From 8e846325a4edf03c0239d205bd3cf61539574114 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 11 Nov 2025 21:01:50 +0530 Subject: [PATCH 4/4] WIP --- src/Response.php | 31 ++-- src/Server/Content/Blob.php | 22 ++- src/Server/Content/Concerns/HasMeta.php | 24 +++ src/Server/Content/Notification.php | 16 +- src/Server/Content/Text.php | 22 ++- src/Server/Primitive.php | 21 ++- src/Server/Prompt.php | 7 +- src/Server/Resource.php | 15 +- src/Server/Tool.php | 22 +-- src/Server/Transport/JsonRpcRequest.php | 8 + src/Support/SecurityScheme.php | 152 ------------------- tests/Unit/Content/BlobTest.php | 44 ++++++ tests/Unit/Content/NotificationTest.php | 65 ++++++++ tests/Unit/Content/TextTest.php | 44 ++++++ tests/Unit/Methods/CallToolTest.php | 48 ++++++ tests/Unit/Prompts/PromptTest.php | 87 +++++++++++ tests/Unit/Resources/ResourceTest.php | 43 ++++++ tests/Unit/ResponseTest.php | 30 ++++ tests/Unit/Support/SecuritySchemeTest.php | 109 ------------- tests/Unit/Tools/ToolTest.php | 22 +-- tests/Unit/Transport/JsonRpcRequestTest.php | 53 +++++++ tests/Unit/Transport/JsonRpcResponseTest.php | 33 ++++ 22 files changed, 585 insertions(+), 333 deletions(-) create mode 100644 src/Server/Content/Concerns/HasMeta.php delete mode 100644 src/Support/SecurityScheme.php create mode 100644 tests/Unit/Prompts/PromptTest.php delete mode 100644 tests/Unit/Support/SecuritySchemeTest.php diff --git a/src/Response.php b/src/Response.php index a64457e8..ae95d10e 100644 --- a/src/Response.php +++ b/src/Response.php @@ -19,43 +19,56 @@ class Response use Conditionable; use Macroable; + /** + * @param array|null $meta + */ protected function __construct( protected Content $content, protected Role $role = Role::USER, protected bool $isError = false, + protected ?array $meta = null, ) { // } /** * @param array $params + * @param array|null $meta */ - public static function notification(string $method, array $params = []): static + public static function notification(string $method, array $params = [], ?array $meta = null): static { - return new static(new Notification($method, $params)); + return new static(new Notification($method, $params, $meta)); } - public static function text(string $text): static + /** + * @param array|null $meta + */ + public static function text(string $text, ?array $meta = null): static { - return new static(new Text($text)); + return new static(new Text($text, $meta)); } /** * @internal * + * @param array|null $meta + * * @throws JsonException */ - public static function json(mixed $content): static + public static function json(mixed $content, ?array $meta = null): static { return static::text(json_encode( $content, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT, - )); + ), $meta); } - public static function blob(string $content): static + /** + * @param array|null $meta + */ + public static function blob(string $content, ?array $meta = null): static { - return new static(new Blob($content)); + return new static(new Blob($content, $meta)); } public static function error(string $text): static @@ -86,7 +99,7 @@ public static function image(): Content public function asAssistant(): static { - return new static($this->content, Role::ASSISTANT, $this->isError); + return new static($this->content, Role::ASSISTANT, $this->isError, $this->meta); } public function isNotification(): bool diff --git a/src/Server/Content/Blob.php b/src/Server/Content/Blob.php index 5f04a1d4..0c6f35f2 100644 --- a/src/Server/Content/Blob.php +++ b/src/Server/Content/Blob.php @@ -5,6 +5,7 @@ namespace Laravel\Mcp\Server\Content; use InvalidArgumentException; +use Laravel\Mcp\Server\Content\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -12,9 +13,16 @@ class Blob implements Content { - public function __construct(protected string $content) - { - // + use HasMeta; + + /** + * @param array|null $meta + */ + public function __construct( + protected string $content, + ?array $meta = null + ) { + $this->meta = $meta; } /** @@ -42,13 +50,13 @@ public function toPrompt(Prompt $prompt): array */ public function toResource(Resource $resource): array { - return [ + return $this->withMeta([ 'blob' => base64_encode($this->content), 'uri' => $resource->uri(), 'name' => $resource->name(), 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), - ]; + ]); } public function __toString(): string @@ -61,9 +69,9 @@ public function __toString(): string */ public function toArray(): array { - return [ + return $this->withMeta([ 'type' => 'blob', 'blob' => $this->content, - ]; + ]); } } diff --git a/src/Server/Content/Concerns/HasMeta.php b/src/Server/Content/Concerns/HasMeta.php new file mode 100644 index 00000000..e7439833 --- /dev/null +++ b/src/Server/Content/Concerns/HasMeta.php @@ -0,0 +1,24 @@ +|null + */ + protected ?array $meta = null; + + /** + * @param array $baseArray + * @return array + */ + protected function withMeta(array $baseArray): array + { + return ($meta = $this->meta) + ? [...$baseArray, '_meta' => $meta] + : $baseArray; + } +} diff --git a/src/Server/Content/Notification.php b/src/Server/Content/Notification.php index 53006291..6d72e4ef 100644 --- a/src/Server/Content/Notification.php +++ b/src/Server/Content/Notification.php @@ -4,6 +4,7 @@ namespace Laravel\Mcp\Server\Content; +use Laravel\Mcp\Server\Content\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -11,12 +12,15 @@ class Notification implements Content { + use HasMeta; + /** * @param array $params + * @param array|null $meta */ - public function __construct(protected string $method, protected array $params) + public function __construct(protected string $method, protected array $params, ?array $meta = null) { - // + $this->meta = $meta; } /** @@ -53,9 +57,15 @@ public function __toString(): string */ public function toArray(): array { + $params = $this->params; + + if ($this->meta !== null && $this->meta !== [] && ! isset($params['_meta'])) { + $params['_meta'] = $this->meta; + } + return [ 'method' => $this->method, - 'params' => $this->params, + 'params' => $params, ]; } } diff --git a/src/Server/Content/Text.php b/src/Server/Content/Text.php index c800a6f5..549cf8b4 100644 --- a/src/Server/Content/Text.php +++ b/src/Server/Content/Text.php @@ -4,6 +4,7 @@ namespace Laravel\Mcp\Server\Content; +use Laravel\Mcp\Server\Content\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -11,9 +12,16 @@ class Text implements Content { - public function __construct(protected string $text) - { - // + use HasMeta; + + /** + * @param array|null $meta + */ + public function __construct( + protected string $text, + ?array $meta = null + ) { + $this->meta = $meta; } /** @@ -37,13 +45,13 @@ public function toPrompt(Prompt $prompt): array */ public function toResource(Resource $resource): array { - return [ + return $this->withMeta([ 'text' => $this->text, 'uri' => $resource->uri(), 'name' => $resource->name(), 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), - ]; + ]); } public function __toString(): string @@ -56,9 +64,9 @@ public function __toString(): string */ public function toArray(): array { - return [ + return $this->withMeta([ 'type' => 'text', 'text' => $this->text, - ]; + ]); } } diff --git a/src/Server/Primitive.php b/src/Server/Primitive.php index 97c08e5c..86007d28 100644 --- a/src/Server/Primitive.php +++ b/src/Server/Primitive.php @@ -20,9 +20,9 @@ abstract class Primitive implements Arrayable protected string $description = ''; /** - * @var array + * @var array|null */ - protected array $meta = []; + protected ?array $meta = null; public function name(): string { @@ -46,9 +46,9 @@ public function description(): string } /** - * @return array + * @return array|null */ - public function meta(): array + public function meta(): ?array { return $this->meta; } @@ -62,6 +62,19 @@ public function eligibleForRegistration(): bool return true; } + /** + * @template T of array + * + * @param T $baseArray + * @return T&array{_meta?: array} + */ + protected function withMeta(array $baseArray): array + { + return ($meta = $this->meta()) + ? [...$baseArray, '_meta' => $meta] + : $baseArray; + } + /** * @return array */ diff --git a/src/Server/Prompt.php b/src/Server/Prompt.php index 1e5dbd9e..276c1808 100644 --- a/src/Server/Prompt.php +++ b/src/Server/Prompt.php @@ -28,11 +28,12 @@ public function toMethodCall(): array } /** - * @return array{name: string, title: string, description: string, arguments: array} + * @return array{name: string, title: string, description: string, arguments: array}>} */ public function toArray(): array { - return [ + // @phpstan-ignore return.type + return $this->withMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), @@ -40,6 +41,6 @@ public function toArray(): array fn (Argument $argument): array => $argument->toArray(), $this->arguments(), ), - ]; + ]); } } diff --git a/src/Server/Resource.php b/src/Server/Resource.php index 26b2eac8..212049b0 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -34,14 +34,25 @@ public function toMethodCall(): array return ['uri' => $this->uri()]; } + /** + * @return array{ + * name: string, + * title: string, + * description: string, + * uri: string, + * mimeType: string, + * _meta?: array + * } + */ public function toArray(): array { - return [ + // @phpstan-ignore return.type + return $this->withMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), 'uri' => $this->uri(), 'mimeType' => $this->mimeType(), - ]; + ]); } } diff --git a/src/Server/Tool.php b/src/Server/Tool.php index 717591de..65eef974 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -6,7 +6,6 @@ use Illuminate\JsonSchema\JsonSchema; use Laravel\Mcp\Server\Contracts\Tools\Annotation; -use Laravel\Mcp\Support\SecurityScheme; use ReflectionAttribute; use ReflectionClass; @@ -20,14 +19,6 @@ public function schema(JsonSchema $schema): array return []; } - /** - * @return array - */ - public function securitySchemes(SecurityScheme $scheme): array - { - return []; - } - /** * @return array */ @@ -61,30 +52,27 @@ public function toMethodCall(): array * description?: string|null, * inputSchema?: array, * annotations?: array|object, - * securitySchemes?: array, * _meta?: array * } */ public function toArray(): array { $annotations = $this->annotations(); + $schema = JsonSchema::object( $this->schema(...), )->toArray(); $schema['properties'] ??= (object) []; - return array_merge([ + // @phpstan-ignore return.type + return $this->withMeta([ '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/Server/Transport/JsonRpcRequest.php b/src/Server/Transport/JsonRpcRequest.php index 1ee32878..7f8db407 100644 --- a/src/Server/Transport/JsonRpcRequest.php +++ b/src/Server/Transport/JsonRpcRequest.php @@ -60,6 +60,14 @@ public function get(string $key, mixed $default = null): mixed return $this->params[$key] ?? $default; } + /** + * @retrun array|null + */ + public function meta(): ?array + { + return is_array($this->params['_meta']) ? $this->params['_meta'] : null; + } + public function toRequest(): Request { return new Request($this->params['arguments'] ?? [], $this->sessionId); diff --git a/src/Support/SecurityScheme.php b/src/Support/SecurityScheme.php deleted file mode 100644 index 00f61e66..00000000 --- a/src/Support/SecurityScheme.php +++ /dev/null @@ -1,152 +0,0 @@ - */ - 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/Content/BlobTest.php b/tests/Unit/Content/BlobTest.php index 7d7823e2..d334ff6d 100644 --- a/tests/Unit/Content/BlobTest.php +++ b/tests/Unit/Content/BlobTest.php @@ -29,6 +29,31 @@ ]); }); +it('preserves meta when converting to a resource payload', function (): void { + $blob = new Blob('raw-bytes', ['encoding' => 'base64']); + $resource = new class extends Resource + { + protected string $uri = 'file://avatar.png'; + + protected string $name = 'avatar'; + + protected string $title = 'User Avatar'; + + protected string $mimeType = 'image/png'; + }; + + $payload = $blob->toResource($resource); + + expect($payload)->toEqual([ + 'blob' => base64_encode('raw-bytes'), + 'uri' => 'file://avatar.png', + 'name' => 'avatar', + 'title' => 'User Avatar', + 'mimeType' => 'image/png', + '_meta' => ['encoding' => 'base64'], + ]); +}); + it('throws when used in tools', function (): void { $blob = new Blob('anything'); @@ -55,3 +80,22 @@ 'blob' => 'bytes', ]); }); + +it('supports meta in constructor', function (): void { + $blob = new Blob('binary-data', ['encoding' => 'base64']); + + expect($blob->toArray())->toEqual([ + 'type' => 'blob', + 'blob' => 'binary-data', + '_meta' => ['encoding' => 'base64'], + ]); +}); + +it('does not include meta if null', function (): void { + $blob = new Blob('data'); + + expect($blob->toArray())->toEqual([ + 'type' => 'blob', + 'blob' => 'data', + ]); +}); diff --git a/tests/Unit/Content/NotificationTest.php b/tests/Unit/Content/NotificationTest.php index 0a2ad155..2f3db15d 100644 --- a/tests/Unit/Content/NotificationTest.php +++ b/tests/Unit/Content/NotificationTest.php @@ -62,3 +62,68 @@ 'params' => ['x' => 1, 'y' => 2], ]); }); + +it('supports constructor meta', function (): void { + $notification = new Notification('test/event', ['data' => 'value'], ['author' => 'system']); + + expect($notification->toArray())->toEqual([ + 'method' => 'test/event', + 'params' => [ + 'data' => 'value', + '_meta' => ['author' => 'system'], + ], + ]); +}); + +it('supports params _meta', function (): void { + $notification = new Notification('test/event', [ + 'data' => 'value', + '_meta' => ['source' => 'params'], + ]); + + expect($notification->toArray())->toEqual([ + 'method' => 'test/event', + 'params' => [ + 'data' => 'value', + '_meta' => ['source' => 'params'], + ], + ]); +}); + +it('keeps params _meta when both params and constructor have meta', function (): void { + $notification = new Notification('test/event', [ + 'data' => 'value', + '_meta' => ['source' => 'params', 'keep' => 'this'], + ], ['source' => 'constructor', 'author' => 'system']); + + expect($notification->toArray())->toEqual([ + 'method' => 'test/event', + 'params' => [ + 'data' => 'value', + '_meta' => [ + 'source' => 'params', // Params _meta is kept + 'keep' => 'this', + ], + ], + ]); +}); + +it('does not include _meta if null', function (): void { + $notification = new Notification('test/event', ['data' => 'value']); + + expect($notification->toArray())->toEqual([ + 'method' => 'test/event', + 'params' => ['data' => 'value'], + ]) + ->and($notification->toArray())->not->toHaveKey('_meta'); +}); + +it('does not include _meta if empty', function (): void { + $notification = new Notification('test/event', ['data' => 'value'], []); + + expect($notification->toArray())->toEqual([ + 'method' => 'test/event', + 'params' => ['data' => 'value'], + ]) + ->and($notification->toArray()['params'])->not->toHaveKey('_meta'); +}); diff --git a/tests/Unit/Content/TextTest.php b/tests/Unit/Content/TextTest.php index ade98a93..89536eb4 100644 --- a/tests/Unit/Content/TextTest.php +++ b/tests/Unit/Content/TextTest.php @@ -29,6 +29,31 @@ ]); }); +it('preserves meta when converting to a resource payload', function (): void { + $text = new Text('Hello world', ['author' => 'John']); + $resource = new class extends Resource + { + protected string $uri = 'file://readme.txt'; + + protected string $name = 'readme'; + + protected string $title = 'Readme File'; + + protected string $mimeType = 'text/plain'; + }; + + $payload = $text->toResource($resource); + + expect($payload)->toEqual([ + 'text' => 'Hello world', + 'uri' => 'file://readme.txt', + 'name' => 'readme', + 'title' => 'Readme File', + 'mimeType' => 'text/plain', + '_meta' => ['author' => 'John'], + ]); +}); + it('may be used in tools', function (): void { $text = new Text('Run me'); @@ -65,3 +90,22 @@ 'text' => 'abc', ]); }); + +it('supports meta in constructor', function (): void { + $text = new Text('Hello', ['author' => 'John']); + + expect($text->toArray())->toEqual([ + 'type' => 'text', + 'text' => 'Hello', + '_meta' => ['author' => 'John'], + ]); +}); + +it('does not include meta if null', function (): void { + $text = new Text('Hello'); + + expect($text->toArray())->toEqual([ + 'type' => 'text', + 'text' => 'Hello', + ]); +}); diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index d8c72c47..77334b7c 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -7,6 +7,7 @@ use Tests\Fixtures\CurrentTimeTool; use Tests\Fixtures\SayHiTool; use Tests\Fixtures\SayHiTwiceTool; +use Tests\Fixtures\SayHiWithMetaTool; it('returns a valid call tool response', function (): void { $request = JsonRpcRequest::from([ @@ -145,6 +146,53 @@ ]); }); +it('includes result meta when responses provide it', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'say-hi-with-meta-tool', + 'arguments' => ['name' => 'John Doe'], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [SayHiWithMetaTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toEqual([ + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Hello, John Doe!', + ], + ], + 'isError' => false, + '_meta' => [ + 'requestId' => 'abc-123', + 'source' => 'tests/fixtures', + ], + ]); +}); + it('may resolve dependencies out of the container', function (): void { $request = JsonRpcRequest::from([ 'jsonrpc' => '2.0', diff --git a/tests/Unit/Prompts/PromptTest.php b/tests/Unit/Prompts/PromptTest.php new file mode 100644 index 00000000..ccab64d7 --- /dev/null +++ b/tests/Unit/Prompts/PromptTest.php @@ -0,0 +1,87 @@ +meta())->toBeNull() + ->and($prompt->toArray())->not->toHaveKey('_meta'); +}); + +it('can have custom meta', function (): void { + $prompt = new class extends Prompt + { + protected ?array $meta = [ + 'category' => 'greeting', + 'tags' => ['hello', 'welcome'], + ]; + + public function description(): string + { + return 'Test prompt'; + } + + public function handle(): Response + { + return Response::text('Hello'); + } + }; + + expect($prompt->toArray()['_meta'])->toEqual([ + 'category' => 'greeting', + 'tags' => ['hello', 'welcome'], + ]); +}); + +it('includes meta in array representation with other fields', function (): void { + $prompt = new class extends Prompt + { + protected string $name = 'greet'; + + protected string $title = 'Greeting Prompt'; + + protected string $description = 'A friendly greeting'; + + protected ?array $meta = [ + 'version' => '1.0', + ]; + + public function handle(): Response + { + return Response::text('Hello'); + } + + public function arguments(): array + { + return [ + new Argument('name', 'User name', true), + ]; + } + }; + + $array = $prompt->toArray(); + + expect($array) + ->toHaveKey('name', 'greet') + ->toHaveKey('title', 'Greeting Prompt') + ->toHaveKey('description', 'A friendly greeting') + ->toHaveKey('arguments') + ->toHaveKey('_meta') + ->and($array['_meta'])->toEqual(['version' => '1.0']) + ->and($array['arguments'])->toHaveCount(1); + +}); diff --git a/tests/Unit/Resources/ResourceTest.php b/tests/Unit/Resources/ResourceTest.php index 9f812743..35888430 100644 --- a/tests/Unit/Resources/ResourceTest.php +++ b/tests/Unit/Resources/ResourceTest.php @@ -141,3 +141,46 @@ public function handle(): string }; expect($resource->description())->toBe('A test resource.'); }); + +it('returns no meta by default', function (): void { + $resource = new class extends Resource + { + public function description(): string + { + return 'Test resource'; + } + + public function handle(): string + { + return 'Content'; + } + }; + + expect($resource->meta())->toBeNull() + ->and($resource->toArray())->not->toHaveKey('_meta'); +}); + +it('can have custom meta', function (): void { + $resource = new class extends Resource + { + protected ?array $meta = [ + 'author' => 'John Doe', + 'version' => '1.0', + ]; + + public function description(): string + { + return 'Test resource'; + } + + public function handle(): string + { + return 'Content'; + } + }; + + expect($resource->toArray()['_meta'])->toEqual([ + 'author' => 'John Doe', + 'version' => '1.0', + ]); +}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index d9484d7a..ca4c8838 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -122,3 +122,33 @@ $content = $response->content(); expect((string) $content)->toBe(json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); }); + +it('creates text response with content meta', function (): void { + $response = Response::text('Hello', ['author' => 'John']); + + expect($response->content())->toBeInstanceOf(Text::class) + ->and($response->content()->toArray())->toHaveKey('_meta') + ->and($response->content()->toArray()['_meta'])->toEqual(['author' => 'John']); +}); + +it('creates blob response with content meta', function (): void { + $response = Response::blob('binary', ['encoding' => 'utf-8']); + + expect($response->content())->toBeInstanceOf(Blob::class) + ->and($response->content()->toArray())->toHaveKey('_meta') + ->and($response->content()->toArray()['_meta'])->toEqual(['encoding' => 'utf-8']); +}); + +it('creates notification response with content meta', function (): void { + $response = Response::notification('test/event', ['data' => 'value'], ['author' => 'system']); + + expect($response->content())->toBeInstanceOf(Notification::class) + ->and($response->content()->toArray()['params'])->toHaveKey('_meta') + ->and($response->content()->toArray()['params']['_meta'])->toEqual(['author' => 'system']); +}); + +it('has no result meta by default', function (): void { + $response = Response::text('Hello'); + + expect($response->meta())->toBeNull(); +}); diff --git a/tests/Unit/Support/SecuritySchemeTest.php b/tests/Unit/Support/SecuritySchemeTest.php deleted file mode 100644 index c37ace9e..00000000 --- a/tests/Unit/Support/SecuritySchemeTest.php +++ /dev/null @@ -1,109 +0,0 @@ -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 12e57b5f..ae29aa71 100644 --- a/tests/Unit/Tools/ToolTest.php +++ b/tests/Unit/Tools/ToolTest.php @@ -6,7 +6,6 @@ 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; @@ -97,7 +96,7 @@ it('returns no meta by default', function (): void { $tool = new TestTool; - expect($tool->meta())->toEqual([]); + expect($tool->meta())->toBeNull(); }); it('can have custom meta', function (): void { @@ -105,13 +104,6 @@ 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 @@ -176,17 +168,7 @@ public function schema(\Illuminate\JsonSchema\JsonSchema $schema): array class CustomMetaTool extends TestTool { - protected array $meta = [ + protected ?array $meta = [ 'key' => 'value', ]; } - -class SecuritySchemesTool extends TestTool -{ - public function securitySchemes(SecurityScheme $scheme): array - { - return [ - $scheme::oauth2('read', 'write'), - ]; - } -} diff --git a/tests/Unit/Transport/JsonRpcRequestTest.php b/tests/Unit/Transport/JsonRpcRequestTest.php index e234f4b7..cf036d82 100644 --- a/tests/Unit/Transport/JsonRpcRequestTest.php +++ b/tests/Unit/Transport/JsonRpcRequestTest.php @@ -113,3 +113,56 @@ expect($requestWithCursor->cursor())->toEqual('CUR123') ->and($requestWithCursor->get('foo'))->toEqual('bar'); }); + +it('extracts _meta from params', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'echo', + '_meta' => [ + 'progressToken' => 'token-123', + 'customKey' => 'customValue', + ], + ], + ]); + + expect($request->params['_meta'] ?? null)->toEqual([ + 'progressToken' => 'token-123', + 'customKey' => 'customValue', + ]) + ->and($request->params)->toHaveKey('_meta') + ->and($request->params)->toHaveKey('name', 'echo'); + + // _meta should remain in params (matches official SDK) +}); + +it('has null meta when not provided', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'echo', + ], + ]); + + expect($request->params['_meta'] ?? null)->toBeNull(); +}); + +it('passes meta to Request object', function (): void { + $jsonRpcRequest = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'arguments' => ['message' => 'Hello'], + '_meta' => ['requestId' => '456'], + ], + ]); + + $request = $jsonRpcRequest->toRequest(); + + expect($request->meta())->toEqual(['requestId' => '456']); +}); diff --git a/tests/Unit/Transport/JsonRpcResponseTest.php b/tests/Unit/Transport/JsonRpcResponseTest.php index 1deb19e0..e8489189 100644 --- a/tests/Unit/Transport/JsonRpcResponseTest.php +++ b/tests/Unit/Transport/JsonRpcResponseTest.php @@ -37,3 +37,36 @@ expect($response->toJson())->toEqual($expectedJson); }); + +it('includes _meta in result when provided in result array', function (): void { + $response = JsonRpcResponse::result( + 1, + [ + 'content' => 'Hello', + '_meta' => [ + 'requestId' => '123', + 'timestamp' => 1234567890, + ], + ] + ); + + $expectedArray = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'content' => 'Hello', + '_meta' => [ + 'requestId' => '123', + 'timestamp' => 1234567890, + ], + ], + ]; + + expect($response->toArray())->toEqual($expectedArray); +}); + +it('does not include _meta when not in result', function (): void { + $response = JsonRpcResponse::result(1, ['content' => 'Hello']); + + expect($response->toArray()['result'])->not->toHaveKey('_meta'); +});