From 4baecb7f519310ca85d1f37f1162a4feb607519a Mon Sep 17 00:00:00 2001 From: zacksmash Date: Thu, 6 Nov 2025 07:35:04 -0700 Subject: [PATCH 01/14] 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 02/14] 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 03/14] =?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 04/14] 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'); +}); From 8b4497c3bd7d444b2d4e4df766b41e48b2e0620e Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 12 Nov 2025 22:03:00 +0530 Subject: [PATCH 05/14] Update the meta $property addition --- README.md | 32 ++++++++++++++ composer.json | 4 +- src/Request.php | 20 ++++++++- src/Response.php | 45 ++++++++++---------- src/Server/Concerns/HasMeta.php | 45 ++++++++++++++++++++ src/Server/Content/Blob.php | 10 ++--- src/Server/Content/Concerns/HasMeta.php | 24 ----------- src/Server/Content/Notification.php | 19 +++------ src/Server/Content/Text.php | 10 ++--- src/Server/Contracts/Content.php | 7 ++++ src/Server/McpServiceProvider.php | 1 + src/Server/Primitive.php | 21 ++-------- src/Server/Transport/JsonRpcRequest.php | 6 +-- tests/Fixtures/SayHiWithMetaTool.php | 40 ++++++++++++++++++ tests/Unit/Content/BlobTest.php | 9 ++-- tests/Unit/Content/NotificationTest.php | 30 +++++++------- tests/Unit/Content/TextTest.php | 9 ++-- tests/Unit/Methods/CallToolTest.php | 7 ++-- tests/Unit/Methods/ListToolsTest.php | 55 +++++++++++++++++++++++++ tests/Unit/ResponseTest.php | 16 +++---- 20 files changed, 276 insertions(+), 134 deletions(-) create mode 100644 src/Server/Concerns/HasMeta.php delete mode 100644 src/Server/Content/Concerns/HasMeta.php create mode 100644 tests/Fixtures/SayHiWithMetaTool.php diff --git a/README.md b/README.md index 6589a20a..88c85764 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,38 @@ Laravel MCP allows you to rapidly build MCP servers for your Laravel application Documentation for Laravel MCP can be found on the [Laravel website](https://laravel.com/docs/mcp). +## Adding Metadata to Tool Responses + +You can include custom metadata in tool responses using the `$meta` property: + +```php +class MyTool extends Tool +{ + protected string $description = 'My tool description'; + + protected ?array $meta = [ + 'requestId' => 'abc-123', + 'source' => 'my-app', + ]; + + public function handle(Request $request): Response + { + return Response::text('Hello!'); + } +} +``` + +You can also add metadata at the content level: + +```php +public function handle(Request $request): Response +{ + return Response::text('Hello!', meta: [ + 'version' => '1.0.0', + ]); +} +``` + ## Contributing Thank you for considering contributing to Laravel MCP! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). diff --git a/composer.json b/composer.json index be814e58..f316e59b 100644 --- a/composer.json +++ b/composer.json @@ -80,8 +80,8 @@ "pint --test", "rector --dry-run" ], - "test:unit": "pest --ci --coverage --min=91.4", - "test:types": "phpstan", + "test:unit": "pest", + "test:types": "phpstan --memory-limit=-1", "test": [ "@test:lint", "@test:unit", diff --git a/src/Request.php b/src/Request.php index 1c9d3e94..c2fe0ad0 100644 --- a/src/Request.php +++ b/src/Request.php @@ -24,10 +24,12 @@ class Request implements Arrayable /** * @param array $arguments + * @param array|null $meta */ public function __construct( protected array $arguments = [], - protected ?string $sessionId = null + protected ?string $sessionId = null, + protected ?array $meta = null, ) { // } @@ -92,6 +94,14 @@ public function sessionId(): ?string return $this->sessionId; } + /** + * @return array|null + */ + public function meta(): ?array + { + return $this->meta; + } + /** * @param array $arguments */ @@ -104,4 +114,12 @@ public function setSessionId(?string $sessionId): void { $this->sessionId = $sessionId; } + + /** + * @param array|null $meta + */ + public function setMeta(?array $meta): void + { + $this->meta = $meta; + } } diff --git a/src/Response.php b/src/Response.php index ae95d10e..11add218 100644 --- a/src/Response.php +++ b/src/Response.php @@ -19,56 +19,43 @@ 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 = [], ?array $meta = null): static + public static function notification(string $method, array $params = []): static { - return new static(new Notification($method, $params, $meta)); + return new static(new Notification($method, $params)); } - /** - * @param array|null $meta - */ - public static function text(string $text, ?array $meta = null): static + public static function text(string $text): static { - return new static(new Text($text, $meta)); + return new static(new Text($text)); } /** * @internal * - * @param array|null $meta - * * @throws JsonException */ - public static function json(mixed $content, ?array $meta = null): static + public static function json(mixed $content): static { return static::text(json_encode( $content, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT, - ), $meta); + )); } - /** - * @param array|null $meta - */ - public static function blob(string $content, ?array $meta = null): static + public static function blob(string $content): static { - return new static(new Blob($content, $meta)); + return new static(new Blob($content)); } public static function error(string $text): static @@ -81,6 +68,20 @@ public function content(): Content return $this->content; } + /** + * Set meta data on the response content. + * Supports both array and key-value signatures. + * Multiple calls will merge the meta data. + * + * @param string|array $keyOrArray + */ + public function withMeta(string|array $keyOrArray, mixed $value = null): static + { + $this->content->setMeta($keyOrArray, $value); + + return $this; + } + /** * @throws NotImplementedException */ @@ -99,7 +100,7 @@ public static function image(): Content public function asAssistant(): static { - return new static($this->content, Role::ASSISTANT, $this->isError, $this->meta); + return new static($this->content, Role::ASSISTANT, $this->isError); } public function isNotification(): bool diff --git a/src/Server/Concerns/HasMeta.php b/src/Server/Concerns/HasMeta.php new file mode 100644 index 00000000..2ae53c55 --- /dev/null +++ b/src/Server/Concerns/HasMeta.php @@ -0,0 +1,45 @@ +|null + */ + protected ?array $meta = null; + + /** + * Set meta data, supporting both array and key-value signatures. + * Multiple calls will merge the meta data. + * + * @param string|array $meta + */ + public function setMeta(string|array $meta, mixed $value = null): void + { + if (is_array($meta)) { + $this->meta = $this->meta + ? array_merge($this->meta, $meta) + : $meta; + } else { + $this->meta = $this->meta + ? [...$this->meta, $meta => $value] + : [$meta => $value]; + } + } + + /** + * @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; + } +} diff --git a/src/Server/Content/Blob.php b/src/Server/Content/Blob.php index 0c6f35f2..5bf8b016 100644 --- a/src/Server/Content/Blob.php +++ b/src/Server/Content/Blob.php @@ -5,7 +5,7 @@ namespace Laravel\Mcp\Server\Content; use InvalidArgumentException; -use Laravel\Mcp\Server\Content\Concerns\HasMeta; +use Laravel\Mcp\Server\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -15,14 +15,10 @@ class Blob implements Content { use HasMeta; - /** - * @param array|null $meta - */ public function __construct( - protected string $content, - ?array $meta = null + protected string $content ) { - $this->meta = $meta; + // } /** diff --git a/src/Server/Content/Concerns/HasMeta.php b/src/Server/Content/Concerns/HasMeta.php deleted file mode 100644 index e7439833..00000000 --- a/src/Server/Content/Concerns/HasMeta.php +++ /dev/null @@ -1,24 +0,0 @@ -|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 6d72e4ef..eb9a8488 100644 --- a/src/Server/Content/Notification.php +++ b/src/Server/Content/Notification.php @@ -4,7 +4,7 @@ namespace Laravel\Mcp\Server\Content; -use Laravel\Mcp\Server\Content\Concerns\HasMeta; +use Laravel\Mcp\Server\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -16,11 +16,10 @@ class Notification implements Content /** * @param array $params - * @param array|null $meta */ - public function __construct(protected string $method, protected array $params, ?array $meta = null) + public function __construct(protected string $method, protected array $params) { - $this->meta = $meta; + // } /** @@ -57,15 +56,9 @@ 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 [ + return $this->withMeta([ 'method' => $this->method, - 'params' => $params, - ]; + 'params' => $this->params, + ]); } } diff --git a/src/Server/Content/Text.php b/src/Server/Content/Text.php index 549cf8b4..058dd1d3 100644 --- a/src/Server/Content/Text.php +++ b/src/Server/Content/Text.php @@ -4,7 +4,7 @@ namespace Laravel\Mcp\Server\Content; -use Laravel\Mcp\Server\Content\Concerns\HasMeta; +use Laravel\Mcp\Server\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -14,14 +14,10 @@ class Text implements Content { use HasMeta; - /** - * @param array|null $meta - */ public function __construct( - protected string $text, - ?array $meta = null + protected string $text ) { - $this->meta = $meta; + // } /** diff --git a/src/Server/Contracts/Content.php b/src/Server/Contracts/Content.php index fa70daf9..339e424f 100644 --- a/src/Server/Contracts/Content.php +++ b/src/Server/Contracts/Content.php @@ -30,5 +30,12 @@ public function toPrompt(Prompt $prompt): array; */ public function toResource(Resource $resource): array; + /** + * Set meta data, supporting both array and key-value signatures. + * + * @param string|array $meta + */ + public function setMeta(string|array $meta, mixed $value = null): void; + public function __toString(): string; } diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index e9972e94..4bbd9af9 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -80,6 +80,7 @@ protected function registerContainerCallbacks(): void $request->setArguments($currentRequest->all()); $request->setSessionId($currentRequest->sessionId()); + $request->setMeta($currentRequest->meta()); } }); } diff --git a/src/Server/Primitive.php b/src/Server/Primitive.php index 86007d28..c2b517f5 100644 --- a/src/Server/Primitive.php +++ b/src/Server/Primitive.php @@ -7,23 +7,21 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Str; +use Laravel\Mcp\Server\Concerns\HasMeta; /** * @implements Arrayable */ abstract class Primitive implements Arrayable { + use HasMeta; + protected string $name = ''; protected string $title = ''; protected string $description = ''; - /** - * @var array|null - */ - protected ?array $meta = null; - public function name(): string { return $this->name === '' @@ -62,19 +60,6 @@ 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/Transport/JsonRpcRequest.php b/src/Server/Transport/JsonRpcRequest.php index 7f8db407..9d1ebe43 100644 --- a/src/Server/Transport/JsonRpcRequest.php +++ b/src/Server/Transport/JsonRpcRequest.php @@ -61,15 +61,15 @@ public function get(string $key, mixed $default = null): mixed } /** - * @retrun array|null + * @return array|null */ public function meta(): ?array { - return is_array($this->params['_meta']) ? $this->params['_meta'] : null; + return isset($this->params['_meta']) && is_array($this->params['_meta']) ? $this->params['_meta'] : null; } public function toRequest(): Request { - return new Request($this->params['arguments'] ?? [], $this->sessionId); + return new Request($this->params['arguments'] ?? [], $this->sessionId, $this->meta()); } } diff --git a/tests/Fixtures/SayHiWithMetaTool.php b/tests/Fixtures/SayHiWithMetaTool.php new file mode 100644 index 00000000..48fb7148 --- /dev/null +++ b/tests/Fixtures/SayHiWithMetaTool.php @@ -0,0 +1,40 @@ + 'abc-123', + 'source' => 'tests/fixtures', + ]; + + public function handle(Request $request): Response + { + $request->validate([ + 'name' => 'required|string', + ]); + + $name = $request->get('name'); + + return Response::text('Hello, '.$name.'!')->withMeta([ + 'test' => 'metadata', + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string() + ->description('The name of the person to greet') + ->required(), + ]; + } +} diff --git a/tests/Unit/Content/BlobTest.php b/tests/Unit/Content/BlobTest.php index d334ff6d..ca6b6e51 100644 --- a/tests/Unit/Content/BlobTest.php +++ b/tests/Unit/Content/BlobTest.php @@ -30,7 +30,9 @@ }); it('preserves meta when converting to a resource payload', function (): void { - $blob = new Blob('raw-bytes', ['encoding' => 'base64']); + $blob = new Blob('raw-bytes'); + $blob->setMeta(['encoding' => 'base64']); + $resource = new class extends Resource { protected string $uri = 'file://avatar.png'; @@ -81,8 +83,9 @@ ]); }); -it('supports meta in constructor', function (): void { - $blob = new Blob('binary-data', ['encoding' => 'base64']); +it('supports meta via setMeta', function (): void { + $blob = new Blob('binary-data'); + $blob->setMeta(['encoding' => 'base64']); expect($blob->toArray())->toEqual([ 'type' => 'blob', diff --git a/tests/Unit/Content/NotificationTest.php b/tests/Unit/Content/NotificationTest.php index 2f3db15d..a8b0fe00 100644 --- a/tests/Unit/Content/NotificationTest.php +++ b/tests/Unit/Content/NotificationTest.php @@ -63,19 +63,18 @@ ]); }); -it('supports constructor meta', function (): void { - $notification = new Notification('test/event', ['data' => 'value'], ['author' => 'system']); +it('supports _meta via setMeta', function (): void { + $notification = new Notification('test/event', ['data' => 'value']); + $notification->setMeta(['author' => 'system']); expect($notification->toArray())->toEqual([ 'method' => 'test/event', - 'params' => [ - 'data' => 'value', - '_meta' => ['author' => 'system'], - ], + 'params' => ['data' => 'value'], + '_meta' => ['author' => 'system'], ]); }); -it('supports params _meta', function (): void { +it('supports _meta in params', function (): void { $notification = new Notification('test/event', [ 'data' => 'value', '_meta' => ['source' => 'params'], @@ -90,21 +89,20 @@ ]); }); -it('keeps params _meta when both params and constructor have meta', function (): void { +it('allows both top-level _meta and params _meta independently', function (): void { $notification = new Notification('test/event', [ 'data' => 'value', '_meta' => ['source' => 'params', 'keep' => 'this'], - ], ['source' => 'constructor', 'author' => 'system']); + ]); + $notification->setMeta(['author' => 'system', 'level' => 'top']); expect($notification->toArray())->toEqual([ 'method' => 'test/event', 'params' => [ 'data' => 'value', - '_meta' => [ - 'source' => 'params', // Params _meta is kept - 'keep' => 'this', - ], + '_meta' => ['source' => 'params', 'keep' => 'this'], ], + '_meta' => ['author' => 'system', 'level' => 'top'], ]); }); @@ -118,12 +116,12 @@ ->and($notification->toArray())->not->toHaveKey('_meta'); }); -it('does not include _meta if empty', function (): void { - $notification = new Notification('test/event', ['data' => 'value'], []); +it('does not include _meta if not set', 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'); + ->and($notification->toArray())->not->toHaveKey('_meta'); }); diff --git a/tests/Unit/Content/TextTest.php b/tests/Unit/Content/TextTest.php index 89536eb4..95a0f6e1 100644 --- a/tests/Unit/Content/TextTest.php +++ b/tests/Unit/Content/TextTest.php @@ -30,7 +30,9 @@ }); it('preserves meta when converting to a resource payload', function (): void { - $text = new Text('Hello world', ['author' => 'John']); + $text = new Text('Hello world'); + $text->setMeta(['author' => 'John']); + $resource = new class extends Resource { protected string $uri = 'file://readme.txt'; @@ -91,8 +93,9 @@ ]); }); -it('supports meta in constructor', function (): void { - $text = new Text('Hello', ['author' => 'John']); +it('supports meta via setMeta', function (): void { + $text = new Text('Hello'); + $text->setMeta(['author' => 'John']); expect($text->toArray())->toEqual([ 'type' => 'text', diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index 77334b7c..5b66ca11 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -183,13 +183,12 @@ [ 'type' => 'text', 'text' => 'Hello, John Doe!', + '_meta' => [ + 'test' => 'metadata', + ], ], ], 'isError' => false, - '_meta' => [ - 'requestId' => 'abc-123', - 'source' => 'tests/fixtures', - ], ]); }); diff --git a/tests/Unit/Methods/ListToolsTest.php b/tests/Unit/Methods/ListToolsTest.php index 027a742e..9c55e7f6 100644 --- a/tests/Unit/Methods/ListToolsTest.php +++ b/tests/Unit/Methods/ListToolsTest.php @@ -6,6 +6,7 @@ use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Tests\Fixtures\SayHiTool; +use Tests\Fixtures\SayHiWithMetaTool; if (! class_exists('Tests\\Unit\\Methods\\DummyTool1')) { for ($i = 1; $i <= 12; $i++) { @@ -350,3 +351,57 @@ public function shouldRegister(Request $request): bool 'tools' => [], ]); }); + +it('includes meta in tool response when tool has meta property', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'list-tools', + 'params' => [], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [SayHiWithMetaTool::class], + resources: [], + prompts: [], + ); + + $listTools = new ListTools; + + $response = $listTools->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + $payload = $response->toArray(); + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toEqual([ + 'tools' => [ + [ + 'name' => 'say-hi-with-meta-tool', + 'title' => 'Say Hi With Meta Tool', + 'description' => 'This tool says hello to a person with metadata', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the person to greet', + ], + ], + 'required' => ['name'], + ], + 'annotations' => (object) [], + '_meta' => [ + 'requestId' => 'abc-123', + 'source' => 'tests/fixtures', + ], + ], + ], + ]); +}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index ca4c8838..30bfd4b8 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -124,7 +124,7 @@ }); it('creates text response with content meta', function (): void { - $response = Response::text('Hello', ['author' => 'John']); + $response = Response::text('Hello')->withMeta(['author' => 'John']); expect($response->content())->toBeInstanceOf(Text::class) ->and($response->content()->toArray())->toHaveKey('_meta') @@ -132,7 +132,7 @@ }); it('creates blob response with content meta', function (): void { - $response = Response::blob('binary', ['encoding' => 'utf-8']); + $response = Response::blob('binary')->withMeta(['encoding' => 'utf-8']); expect($response->content())->toBeInstanceOf(Blob::class) ->and($response->content()->toArray())->toHaveKey('_meta') @@ -140,15 +140,9 @@ }); it('creates notification response with content meta', function (): void { - $response = Response::notification('test/event', ['data' => 'value'], ['author' => 'system']); + $response = Response::notification('test/event', ['data' => 'value'])->withMeta(['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(); + ->and($response->content()->toArray())->toHaveKey('_meta') + ->and($response->content()->toArray()['_meta'])->toEqual(['author' => 'system']); }); From ca70ae47d1e613fd70813f168c5204a36124cbd4 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 13 Nov 2025 20:29:02 +0530 Subject: [PATCH 06/14] Introduce `ResponseFactory` for handling responses with result-level metadata. --- README.md | 32 ----- src/ResponseFactory.php | 69 +++++++++ src/Server/Concerns/HasMeta.php | 21 ++- src/Server/Content/Blob.php | 4 +- src/Server/Content/Notification.php | 12 +- src/Server/Content/Text.php | 4 +- src/Server/Methods/CallTool.php | 12 +- .../Concerns/InteractsWithResponses.php | 32 +++-- src/Server/Methods/GetPrompt.php | 10 +- src/Server/Methods/ReadResource.php | 8 +- src/Server/Prompt.php | 2 +- src/Server/Resource.php | 2 +- src/Server/Tool.php | 2 +- tests/Fixtures/PromptWithResultMetaPrompt.php | 24 ++++ .../ResourceWithResultMetaResource.php | 35 +++++ tests/Fixtures/ToolWithBothMetaTool.php | 30 ++++ tests/Fixtures/ToolWithResultMetaTool.php | 29 ++++ tests/Unit/Content/NotificationTest.php | 7 +- tests/Unit/Methods/CallToolTest.php | 98 +++++++++++++ tests/Unit/Methods/GetPromptTest.php | 54 +++++++ tests/Unit/Methods/ReadResourceTest.php | 48 +++++++ tests/Unit/ResponseFactoryTest.php | 132 ++++++++++++++++++ tests/Unit/ResponseTest.php | 6 +- 23 files changed, 585 insertions(+), 88 deletions(-) create mode 100644 src/ResponseFactory.php create mode 100644 tests/Fixtures/PromptWithResultMetaPrompt.php create mode 100644 tests/Fixtures/ResourceWithResultMetaResource.php create mode 100644 tests/Fixtures/ToolWithBothMetaTool.php create mode 100644 tests/Fixtures/ToolWithResultMetaTool.php create mode 100644 tests/Unit/ResponseFactoryTest.php diff --git a/README.md b/README.md index 88c85764..6589a20a 100644 --- a/README.md +++ b/README.md @@ -18,38 +18,6 @@ Laravel MCP allows you to rapidly build MCP servers for your Laravel application Documentation for Laravel MCP can be found on the [Laravel website](https://laravel.com/docs/mcp). -## Adding Metadata to Tool Responses - -You can include custom metadata in tool responses using the `$meta` property: - -```php -class MyTool extends Tool -{ - protected string $description = 'My tool description'; - - protected ?array $meta = [ - 'requestId' => 'abc-123', - 'source' => 'my-app', - ]; - - public function handle(Request $request): Response - { - return Response::text('Hello!'); - } -} -``` - -You can also add metadata at the content level: - -```php -public function handle(Request $request): Response -{ - return Response::text('Hello!', meta: [ - 'version' => '1.0.0', - ]); -} -``` - ## Contributing Thank you for considering contributing to Laravel MCP! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php new file mode 100644 index 00000000..b6376f8e --- /dev/null +++ b/src/ResponseFactory.php @@ -0,0 +1,69 @@ + + */ + protected Collection $responses; + + /** + * @param Response|array $responses + */ + protected function __construct(Response|array $responses) + { + $this->responses = collect(Arr::wrap($responses)); + } + + /** + * @param Response|array $responses + */ + public static function make(Response|array $responses): static + { + if (is_array($responses)) { + collect($responses)->ensure(Response::class); + } + + return new static($responses); + } + + /** + * @param string|array $meta + */ + public function withMeta(string|array $meta, mixed $value = null): static + { + $this->setMeta($meta, $value); + + return $this; + } + + /** + * @return Collection + */ + public function responses(): Collection + { + return $this->responses; + } + + /** + * @return array|null + */ + public function getMeta(): ?array + { + return $this->meta; + } +} diff --git a/src/Server/Concerns/HasMeta.php b/src/Server/Concerns/HasMeta.php index 2ae53c55..a3dd822c 100644 --- a/src/Server/Concerns/HasMeta.php +++ b/src/Server/Concerns/HasMeta.php @@ -12,22 +12,19 @@ trait HasMeta protected ?array $meta = null; /** - * Set meta data, supporting both array and key-value signatures. - * Multiple calls will merge the meta data. - * * @param string|array $meta */ public function setMeta(string|array $meta, mixed $value = null): void { - if (is_array($meta)) { - $this->meta = $this->meta - ? array_merge($this->meta, $meta) - : $meta; - } else { - $this->meta = $this->meta - ? [...$this->meta, $meta => $value] - : [$meta => $value]; + $this->meta ??= []; + + if (! is_array($meta)) { + $this->meta[$meta] = $value; + + return; } + + $this->meta = array_merge($this->meta, $meta); } /** @@ -36,7 +33,7 @@ public function setMeta(string|array $meta, mixed $value = null): void * @param T $baseArray * @return T&array{_meta?: array} */ - protected function withMeta(array $baseArray): array + public function mergeMeta(array $baseArray): array { return ($meta = $this->meta) ? [...$baseArray, '_meta' => $meta] diff --git a/src/Server/Content/Blob.php b/src/Server/Content/Blob.php index 5bf8b016..fa5be748 100644 --- a/src/Server/Content/Blob.php +++ b/src/Server/Content/Blob.php @@ -46,7 +46,7 @@ public function toPrompt(Prompt $prompt): array */ public function toResource(Resource $resource): array { - return $this->withMeta([ + return $this->mergeMeta([ 'blob' => base64_encode($this->content), 'uri' => $resource->uri(), 'name' => $resource->name(), @@ -65,7 +65,7 @@ public function __toString(): string */ public function toArray(): array { - return $this->withMeta([ + return $this->mergeMeta([ 'type' => 'blob', 'blob' => $this->content, ]); diff --git a/src/Server/Content/Notification.php b/src/Server/Content/Notification.php index eb9a8488..8d20566b 100644 --- a/src/Server/Content/Notification.php +++ b/src/Server/Content/Notification.php @@ -56,9 +56,15 @@ public function __toString(): string */ public function toArray(): array { - return $this->withMeta([ + $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 058dd1d3..f6a56841 100644 --- a/src/Server/Content/Text.php +++ b/src/Server/Content/Text.php @@ -41,7 +41,7 @@ public function toPrompt(Prompt $prompt): array */ public function toResource(Resource $resource): array { - return $this->withMeta([ + return $this->mergeMeta([ 'text' => $this->text, 'uri' => $resource->uri(), 'name' => $resource->name(), @@ -60,7 +60,7 @@ public function __toString(): string */ public function toArray(): array { - return $this->withMeta([ + return $this->mergeMeta([ 'type' => 'text', 'text' => $this->text, ]); diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index e65bad3a..ff88581d 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -6,9 +6,9 @@ use Generator; use Illuminate\Container\Container; -use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -61,13 +61,13 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{content: array>, isError: bool} + * @return callable(ResponseFactory): array */ protected function serializable(Tool $tool): callable { - return fn (Collection $responses): array => [ - 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), - 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), - ]; + return fn (ResponseFactory $factory): array => $factory->mergeMeta([ + 'content' => $factory->responses()->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), + 'isError' => $factory->responses()->contains(fn (Response $response): bool => $response->isError()), + ]); } } diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 8e7ada3b..5d8c2729 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -5,8 +5,10 @@ namespace Laravel\Mcp\Server\Methods\Concerns; use Generator; +use Illuminate\Support\Arr; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -16,37 +18,41 @@ trait InteractsWithResponses { /** - * @param array|Response|string $response + * @param array|Response|ResponseFactory|string $response + * + * @throws JsonRpcException */ - protected function toJsonRpcResponse(JsonRpcRequest $request, array|Response|string $response, callable $serializable): JsonRpcResponse + protected function toJsonRpcResponse(JsonRpcRequest $request, array|Response|ResponseFactory|string $response, callable $serializable): JsonRpcResponse { - $responses = collect( - is_array($response) ? $response : [$response] - )->map(fn (Response|string $response): Response => $response instanceof Response - ? $response - : ($this->isBinary($response) ? Response::blob($response) : Response::text($response)) - ); + if (! ($response instanceof ResponseFactory)) { + $responses = collect(Arr::wrap($response))->map(fn ($item): Response => $item instanceof Response + ? $item + : ($this->isBinary($item) ? Response::blob($item) : Response::text($item)) + ); + + $response = ResponseFactory::make($responses->all()); + } - $responses->each(function (Response $response) use ($request): void { + $response->responses()->each(function (Response $response) use ($request): void { if (! $this instanceof Errable && $response->isError()) { throw new JsonRpcException( - // @phpstan-ignore-next-line - $response->content()->__toString(), + $response->content()->__toString(), // @phpstan-ignore-line -32603, $request->id, ); } }); - return JsonRpcResponse::result($request->id, $serializable($responses)); + return JsonRpcResponse::result($request->id, $serializable($response)); } /** - * @param iterable $responses + * @param iterable $responses * @return Generator */ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $responses, callable $serializable): Generator { + /** @var array $pendingResponses */ $pendingResponses = []; try { diff --git a/src/Server/Methods/GetPrompt.php b/src/Server/Methods/GetPrompt.php index a9b00b85..872e2ae0 100644 --- a/src/Server/Methods/GetPrompt.php +++ b/src/Server/Methods/GetPrompt.php @@ -6,9 +6,9 @@ use Generator; use Illuminate\Container\Container; -use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses; @@ -59,16 +59,16 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{description?: string, messages: array}>} + * @return callable(ResponseFactory): array */ protected function serializable(Prompt $prompt): callable { - return fn (Collection $responses): array => [ + return fn (ResponseFactory $factory): array => $factory->mergeMeta([ 'description' => $prompt->description(), - 'messages' => $responses->map(fn (Response $response): array => [ + 'messages' => $factory->responses()->map(fn (Response $response): array => [ 'role' => $response->role()->value, 'content' => $response->content()->toPrompt($prompt), ])->all(), - ]; + ]); } } diff --git a/src/Server/Methods/ReadResource.php b/src/Server/Methods/ReadResource.php index 50718a1f..85721350 100644 --- a/src/Server/Methods/ReadResource.php +++ b/src/Server/Methods/ReadResource.php @@ -6,9 +6,9 @@ use Generator; use Illuminate\Container\Container; -use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses; @@ -60,8 +60,8 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat protected function serializable(Resource $resource): callable { - return fn (Collection $responses): array => [ - 'contents' => $responses->map(fn (Response $response): array => $response->content()->toResource($resource))->all(), - ]; + return fn (ResponseFactory $factory): array => $factory->mergeMeta([ + 'contents' => $factory->responses()->map(fn (Response $response): array => $response->content()->toResource($resource))->all(), + ]); } } diff --git a/src/Server/Prompt.php b/src/Server/Prompt.php index 276c1808..eb7daa68 100644 --- a/src/Server/Prompt.php +++ b/src/Server/Prompt.php @@ -33,7 +33,7 @@ public function toMethodCall(): array public function toArray(): array { // @phpstan-ignore return.type - return $this->withMeta([ + return $this->mergeMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), diff --git a/src/Server/Resource.php b/src/Server/Resource.php index 212049b0..b65492fd 100644 --- a/src/Server/Resource.php +++ b/src/Server/Resource.php @@ -47,7 +47,7 @@ public function toMethodCall(): array public function toArray(): array { // @phpstan-ignore return.type - return $this->withMeta([ + return $this->mergeMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), diff --git a/src/Server/Tool.php b/src/Server/Tool.php index 65eef974..0019841e 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -66,7 +66,7 @@ public function toArray(): array $schema['properties'] ??= (object) []; // @phpstan-ignore return.type - return $this->withMeta([ + return $this->mergeMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), diff --git a/tests/Fixtures/PromptWithResultMetaPrompt.php b/tests/Fixtures/PromptWithResultMetaPrompt.php new file mode 100644 index 00000000..d7b6d6ce --- /dev/null +++ b/tests/Fixtures/PromptWithResultMetaPrompt.php @@ -0,0 +1,24 @@ +withMeta(['key' => 'value']) + )->withMeta([ + 'prompt_version' => '2.0', + 'last_updated' => now()->toDateString(), + ]); + } +} diff --git a/tests/Fixtures/ResourceWithResultMetaResource.php b/tests/Fixtures/ResourceWithResultMetaResource.php new file mode 100644 index 00000000..96b8e333 --- /dev/null +++ b/tests/Fixtures/ResourceWithResultMetaResource.php @@ -0,0 +1,35 @@ +withMeta([ + 'last_modified' => now()->toIso8601String(), + 'version' => '1.0', + ]); + } + + public function uri(): string + { + return 'file://resources/with-result-meta.txt'; + } + + public function mimeType(): string + { + return 'text/plain'; + } +} diff --git a/tests/Fixtures/ToolWithBothMetaTool.php b/tests/Fixtures/ToolWithBothMetaTool.php new file mode 100644 index 00000000..0ec55073 --- /dev/null +++ b/tests/Fixtures/ToolWithBothMetaTool.php @@ -0,0 +1,30 @@ +withMeta(['content_index' => 1]), + Response::text('Second response')->withMeta(['content_index' => 2]), + ])->withMeta([ + 'result_key' => 'result_value', + 'total_responses' => 2, + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Fixtures/ToolWithResultMetaTool.php b/tests/Fixtures/ToolWithResultMetaTool.php new file mode 100644 index 00000000..5ee57457 --- /dev/null +++ b/tests/Fixtures/ToolWithResultMetaTool.php @@ -0,0 +1,29 @@ +withMeta([ + 'session_id' => $request->sessionId(), + 'timestamp' => now()->toIso8601String(), + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Unit/Content/NotificationTest.php b/tests/Unit/Content/NotificationTest.php index a8b0fe00..de986d5a 100644 --- a/tests/Unit/Content/NotificationTest.php +++ b/tests/Unit/Content/NotificationTest.php @@ -69,8 +69,10 @@ expect($notification->toArray())->toEqual([ 'method' => 'test/event', - 'params' => ['data' => 'value'], - '_meta' => ['author' => 'system'], + 'params' => [ + 'data' => 'value', + '_meta' => ['author' => 'system'], + ], ]); }); @@ -102,7 +104,6 @@ 'data' => 'value', '_meta' => ['source' => 'params', 'keep' => 'this'], ], - '_meta' => ['author' => 'system', 'level' => 'top'], ]); }); diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index 5b66ca11..6f40a73e 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -8,6 +8,8 @@ use Tests\Fixtures\SayHiTool; use Tests\Fixtures\SayHiTwiceTool; use Tests\Fixtures\SayHiWithMetaTool; +use Tests\Fixtures\ToolWithBothMetaTool; +use Tests\Fixtures\ToolWithResultMetaTool; it('returns a valid call tool response', function (): void { $request = JsonRpcRequest::from([ @@ -229,3 +231,99 @@ ->and($type)->toEqual('text') ->and($text)->toContain('The current time is '); }); + +it('returns a result with result-level meta when using ResponseFactory', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'tool-with-result-meta-tool', + 'arguments' => [], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [ToolWithResultMetaTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toHaveKey('_meta') + ->and($payload['result']['_meta'])->toHaveKey('session_id') + ->and($payload['result']['_meta'])->toHaveKey('timestamp') + ->and($payload['result']['content'])->toEqual([ + [ + 'type' => 'text', + 'text' => 'Tool response with result meta', + ], + ]) + ->and($payload['result']['isError'])->toBeFalse(); +}); + +it('separates content-level meta from result-level meta', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'tool-with-both-meta-tool', + 'arguments' => [], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [ToolWithBothMetaTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['result'])->toHaveKey('_meta') + ->and($payload['result']['_meta'])->toEqual([ + 'result_key' => 'result_value', + 'total_responses' => 2, + ]) + ->and($payload['result']['content'][0])->toEqual([ + 'type' => 'text', + 'text' => 'First response', + '_meta' => ['content_index' => 1], + ]) + ->and($payload['result']['content'][1])->toEqual([ + 'type' => 'text', + 'text' => 'Second response', + '_meta' => ['content_index' => 2], + ]); +}); diff --git a/tests/Unit/Methods/GetPromptTest.php b/tests/Unit/Methods/GetPromptTest.php index 62ace7e6..56597b82 100644 --- a/tests/Unit/Methods/GetPromptTest.php +++ b/tests/Unit/Methods/GetPromptTest.php @@ -5,6 +5,7 @@ use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; +use Tests\Fixtures\PromptWithResultMetaPrompt; use Tests\Fixtures\ReviewMyCodePrompt; use Tests\Fixtures\TellMeHiPrompt; @@ -195,3 +196,56 @@ expect($payload['result'])->toHaveKey('description'); expect($payload['result'])->toHaveKey('messages'); }); + +it('returns a prompt result with result-level meta when using ResponseFactory', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/get', + 'params' => [ + 'name' => 'prompt-with-result-meta-prompt', + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [], + prompts: [PromptWithResultMetaPrompt::class], + ); + + $method = new GetPrompt; + + $response = $method->handle($request, $context); + + $payload = $response->toArray(); + + expect($payload) + ->toBeArray() + ->id->toBe(1) + ->result->toMatchArray([ + '_meta' => [ + 'prompt_version' => '2.0', + 'last_updated' => now()->toDateString(), + ], + 'description' => 'Prompt with result-level meta', + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'text', + 'text' => 'Prompt instructions with result meta', + '_meta' => [ + 'key' => 'value', + ], + ], + ], + ], + ]); +}); diff --git a/tests/Unit/Methods/ReadResourceTest.php b/tests/Unit/Methods/ReadResourceTest.php index c8a479b1..609a62f3 100644 --- a/tests/Unit/Methods/ReadResourceTest.php +++ b/tests/Unit/Methods/ReadResourceTest.php @@ -4,7 +4,10 @@ use Laravel\Mcp\Server\Exceptions\JsonRpcException; use Laravel\Mcp\Server\Methods\ReadResource; +use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; +use Laravel\Mcp\Server\Transport\JsonRpcResponse; +use Tests\Fixtures\ResourceWithResultMetaResource; it('returns a valid resource result', function (): void { $resource = $this->makeResource('resource-content'); @@ -78,3 +81,48 @@ $readResource->handle($jsonRpcRequest, $context); }); + +it('returns resource result with result-level meta when using ResponseFactory', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'file://resources/with-result-meta.txt', + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [], + resources: [ResourceWithResultMetaResource::class], + prompts: [], + ); + + $method = new ReadResource; + + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toHaveKey('_meta') + ->and($payload['result']['_meta'])->toHaveKey('last_modified') + ->and($payload['result']['_meta'])->toHaveKey('version') + ->and($payload['result']['_meta']['version'])->toBe('1.0') + ->and($payload['result']['contents'][0])->toEqual([ + 'text' => 'Resource content with result meta', + 'uri' => 'file://resources/with-result-meta.txt', + 'name' => 'resource-with-result-meta-resource', + 'title' => 'Resource With Result Meta Resource', + 'mimeType' => 'text/plain', + ]); +}); diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php new file mode 100644 index 00000000..1874ad8a --- /dev/null +++ b/tests/Unit/ResponseFactoryTest.php @@ -0,0 +1,132 @@ +toBeInstanceOf(ResponseFactory::class); + expect($factory->responses()) + ->toHaveCount(1) + ->first()->toBe($response); +}); + +it('creates a factory with multiple responses', function (): void { + $response1 = Response::text('First'); + $response2 = Response::text('Second'); + $factory = ResponseFactory::make([$response1, $response2]); + + expect($factory)->toBeInstanceOf(ResponseFactory::class); + expect($factory->responses()) + ->toHaveCount(2) + ->first()->toBe($response1); + expect($factory->responses()->last())->toBe($response2); +}); + +it('supports fluent withMeta for result-level metadata', function (): void { + $factory = ResponseFactory::make(Response::text('Hello')) + ->withMeta(['key' => 'value']); + + expect($factory->getMeta())->toEqual(['key' => 'value']); +}); + +it('supports withMeta with key-value signature', function (): void { + $factory = ResponseFactory::make(Response::text('Hello')) + ->withMeta('key1', 'value1') + ->withMeta('key2', 'value2'); + + expect($factory->getMeta())->toEqual(['key1' => 'value1', 'key2' => 'value2']); +}); + +it('merges multiple withMeta calls', function (): void { + $factory = ResponseFactory::make(Response::text('Hello')) + ->withMeta(['key1' => 'value1']) + ->withMeta(['key2' => 'value1']) + ->withMeta(['key2' => 'value2']); + + expect($factory->getMeta())->toEqual(['key1' => 'value1', 'key2' => 'value2']); +}); + +it('returns null for getMeta when no meta is set', function (): void { + $factory = ResponseFactory::make(Response::text('Hello')); + + expect($factory->getMeta())->toBeNull(); +}); + +it('supports Conditionable trait', function (): void { + $factory = ResponseFactory::make(Response::text('Hello')) + ->when(true, fn ($f): ResponseFactory => $f->withMeta(['conditional' => 'yes'])); + + expect($factory->getMeta())->toEqual(['conditional' => 'yes']); +}); + +it('supports unless from Conditionable trait', function (): void { + $factory = ResponseFactory::make(Response::text('Hello')) + ->unless(false, fn ($f): ResponseFactory => $f->withMeta(['unless' => 'applied'])); + + expect($factory->getMeta())->toEqual(['unless' => 'applied']); +}); + +it('separates content-level meta from result-level meta', function (): void { + $response = Response::text('Hello')->withMeta(['content_meta' => 'content_value']); + $factory = ResponseFactory::make($response) + ->withMeta(['result_meta' => 'result_value']); + + expect($factory->getMeta())->toEqual(['result_meta' => 'result_value']); + expect($factory->responses()->first())->toBe($response); +}); + +it('throws exception when array contains non-Response object', function (): void { + expect(fn (): ResponseFactory => ResponseFactory::make([ + Response::text('Valid'), + 'Invalid string', + ]))->toThrow( + UnexpectedValueException::class, + ); +}); + +it('throws exception when array contains nested ResponseFactory', function (): void { + $nestedFactory = ResponseFactory::make(Response::text('Nested')); + + expect(fn (): ResponseFactory => ResponseFactory::make([ + Response::text('First'), + $nestedFactory, + Response::text('Third'), + ]))->toThrow( + UnexpectedValueException::class, + ); +}); + +it('throws exception when an array contains null', function (): void { + expect(fn (): ResponseFactory => ResponseFactory::make([ + Response::text('Valid'), + null, + ]))->toThrow( + UnexpectedValueException::class, + ); +}); + +it('accepts a single Response without validation error', function (): void { + $response = Response::text('Single response'); + $factory = ResponseFactory::make($response); + + expect($factory->responses()) + ->toHaveCount(1) + ->first()->toBe($response); +}); + +it('accepts array of valid Response objects', function (): void { + $responses = [ + Response::text('First'), + Response::text('Second'), + Response::blob('binary'), + ]; + + $factory = ResponseFactory::make($responses); + + expect($factory->responses()) + ->toHaveCount(3) + ->all()->toBe($responses); +}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 30bfd4b8..3d2cda73 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -139,10 +139,10 @@ ->and($response->content()->toArray()['_meta'])->toEqual(['encoding' => 'utf-8']); }); -it('creates notification response with content meta', function (): void { +it('creates a notification response with content meta', function (): void { $response = Response::notification('test/event', ['data' => 'value'])->withMeta(['author' => 'system']); expect($response->content())->toBeInstanceOf(Notification::class) - ->and($response->content()->toArray())->toHaveKey('_meta') - ->and($response->content()->toArray()['_meta'])->toEqual(['author' => 'system']); + ->and($response->content()->toArray()['params'])->toHaveKey('_meta') + ->and($response->content()->toArray()['params']['_meta'])->toEqual(['author' => 'system']); }); From 504418560c343e383b450f62022948e50d4df4d7 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 13 Nov 2025 22:44:15 +0530 Subject: [PATCH 07/14] Replace `UnexpectedValueException` with `InvalidArgumentException` in `ResponseFactory` and improve type validation logic. --- src/ResponseFactory.php | 10 +++++++++- src/Server/Methods/Concerns/InteractsWithResponses.php | 4 ++++ tests/Unit/ResponseFactoryTest.php | 6 +++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index b6376f8e..d67dc757 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use Laravel\Mcp\Server\Concerns\HasMeta; class ResponseFactory @@ -35,7 +36,14 @@ protected function __construct(Response|array $responses) public static function make(Response|array $responses): static { if (is_array($responses)) { - collect($responses)->ensure(Response::class); + foreach ($responses as $index => $response) { + // @phpstan-ignore instanceof.alwaysTrue + if (! $response instanceof Response) { + throw new InvalidArgumentException( + "Invalid response type at index {$index}: Expected ".Response::class.', but received '.get_debug_type($response).'.' + ); + } + } } return new static($responses); diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 5d8c2729..2762ec9c 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -24,6 +24,10 @@ trait InteractsWithResponses */ protected function toJsonRpcResponse(JsonRpcRequest $request, array|Response|ResponseFactory|string $response, callable $serializable): JsonRpcResponse { + if (is_array($response) && count($response) === 1) { + $response = Arr::first($response); + } + if (! ($response instanceof ResponseFactory)) { $responses = collect(Arr::wrap($response))->map(fn ($item): Response => $item instanceof Response ? $item diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php index 1874ad8a..71fb3302 100644 --- a/tests/Unit/ResponseFactoryTest.php +++ b/tests/Unit/ResponseFactoryTest.php @@ -83,7 +83,7 @@ Response::text('Valid'), 'Invalid string', ]))->toThrow( - UnexpectedValueException::class, + InvalidArgumentException::class, ); }); @@ -95,7 +95,7 @@ $nestedFactory, Response::text('Third'), ]))->toThrow( - UnexpectedValueException::class, + InvalidArgumentException::class, ); }); @@ -104,7 +104,7 @@ Response::text('Valid'), null, ]))->toThrow( - UnexpectedValueException::class, + InvalidArgumentException::class, ); }); From 1c68e8b4ff35ec6bff0cdd853cf8371dd6cafb7f Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 13 Nov 2025 23:09:13 +0530 Subject: [PATCH 08/14] Formatting --- composer.json | 4 ++-- src/Server/Concerns/HasMeta.php | 6 ++++++ src/Server/Content/Blob.php | 5 ++--- src/Server/Content/Text.php | 5 ++--- src/Server/Contracts/Content.php | 2 -- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index f316e59b..be814e58 100644 --- a/composer.json +++ b/composer.json @@ -80,8 +80,8 @@ "pint --test", "rector --dry-run" ], - "test:unit": "pest", - "test:types": "phpstan --memory-limit=-1", + "test:unit": "pest --ci --coverage --min=91.4", + "test:types": "phpstan", "test": [ "@test:lint", "@test:unit", diff --git a/src/Server/Concerns/HasMeta.php b/src/Server/Concerns/HasMeta.php index a3dd822c..faaabcdf 100644 --- a/src/Server/Concerns/HasMeta.php +++ b/src/Server/Concerns/HasMeta.php @@ -4,6 +4,8 @@ namespace Laravel\Mcp\Server\Concerns; +use InvalidArgumentException; + trait HasMeta { /** @@ -19,6 +21,10 @@ public function setMeta(string|array $meta, mixed $value = null): void $this->meta ??= []; if (! is_array($meta)) { + if (! $value) { + throw new InvalidArgumentException('Value is required when using key-value signature.'); + } + $this->meta[$meta] = $value; return; diff --git a/src/Server/Content/Blob.php b/src/Server/Content/Blob.php index fa5be748..f7b9f714 100644 --- a/src/Server/Content/Blob.php +++ b/src/Server/Content/Blob.php @@ -15,9 +15,8 @@ class Blob implements Content { use HasMeta; - public function __construct( - protected string $content - ) { + public function __construct(protected string $content) + { // } diff --git a/src/Server/Content/Text.php b/src/Server/Content/Text.php index f6a56841..602d9bdb 100644 --- a/src/Server/Content/Text.php +++ b/src/Server/Content/Text.php @@ -14,9 +14,8 @@ class Text implements Content { use HasMeta; - public function __construct( - protected string $text - ) { + public function __construct(protected string $text) + { // } diff --git a/src/Server/Contracts/Content.php b/src/Server/Contracts/Content.php index 339e424f..65c45dae 100644 --- a/src/Server/Contracts/Content.php +++ b/src/Server/Contracts/Content.php @@ -31,8 +31,6 @@ public function toPrompt(Prompt $prompt): array; public function toResource(Resource $resource): array; /** - * Set meta data, supporting both array and key-value signatures. - * * @param string|array $meta */ public function setMeta(string|array $meta, mixed $value = null): void; From be51ccfdf25f5c767c2a81761058ffaa83fd848d Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 14 Nov 2025 00:12:44 +0530 Subject: [PATCH 09/14] Fix test and change the API --- src/Response.php | 30 +++++-- src/ResponseFactory.php | 24 +----- src/Server/Concerns/HasMeta.php | 2 +- .../Concerns/InteractsWithResponses.php | 2 +- tests/Fixtures/PromptWithResultMetaPrompt.php | 2 +- .../ResourceWithResultMetaResource.php | 2 +- tests/Fixtures/ToolWithBothMetaTool.php | 2 +- tests/Fixtures/ToolWithResultMetaTool.php | 5 +- tests/Unit/Content/BlobTest.php | 6 +- tests/Unit/Content/NotificationTest.php | 36 +-------- tests/Unit/Methods/CallToolTest.php | 81 +++++++++++-------- tests/Unit/ResponseFactoryTest.php | 52 +++--------- tests/Unit/ResponseTest.php | 31 +++++++ 13 files changed, 129 insertions(+), 146 deletions(-) diff --git a/src/Response.php b/src/Response.php index 11add218..3627314a 100644 --- a/src/Response.php +++ b/src/Response.php @@ -6,6 +6,7 @@ use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use JsonException; use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; @@ -69,15 +70,30 @@ public function content(): Content } /** - * Set meta data on the response content. - * Supports both array and key-value signatures. - * Multiple calls will merge the meta data. - * - * @param string|array $keyOrArray + * @param Response|array $responses + */ + public static function make(Response|array $responses): ResponseFactory + { + if (is_array($responses)) { + foreach ($responses as $index => $response) { + // @phpstan-ignore instanceof.alwaysTrue + if (! $response instanceof Response) { + throw new InvalidArgumentException( + "Invalid response type at index {$index}: Expected ".Response::class.', but received '.get_debug_type($response).'.' + ); + } + } + } + + return new ResponseFactory($responses); + } + + /** + * @param string|array $meta */ - public function withMeta(string|array $keyOrArray, mixed $value = null): static + public function withMeta(string|array $meta, mixed $value = null): static { - $this->content->setMeta($keyOrArray, $value); + $this->content->setMeta($meta, $value); return $this; } diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index d67dc757..7fab8012 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -8,7 +8,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; -use InvalidArgumentException; use Laravel\Mcp\Server\Concerns\HasMeta; class ResponseFactory @@ -20,35 +19,16 @@ class ResponseFactory /** * @var Collection */ - protected Collection $responses; + public Collection $responses; /** * @param Response|array $responses */ - protected function __construct(Response|array $responses) + public function __construct(Response|array $responses) { $this->responses = collect(Arr::wrap($responses)); } - /** - * @param Response|array $responses - */ - public static function make(Response|array $responses): static - { - if (is_array($responses)) { - foreach ($responses as $index => $response) { - // @phpstan-ignore instanceof.alwaysTrue - if (! $response instanceof Response) { - throw new InvalidArgumentException( - "Invalid response type at index {$index}: Expected ".Response::class.', but received '.get_debug_type($response).'.' - ); - } - } - } - - return new static($responses); - } - /** * @param string|array $meta */ diff --git a/src/Server/Concerns/HasMeta.php b/src/Server/Concerns/HasMeta.php index faaabcdf..282dd2d1 100644 --- a/src/Server/Concerns/HasMeta.php +++ b/src/Server/Concerns/HasMeta.php @@ -21,7 +21,7 @@ public function setMeta(string|array $meta, mixed $value = null): void $this->meta ??= []; if (! is_array($meta)) { - if (! $value) { + if (is_null($value)) { throw new InvalidArgumentException('Value is required when using key-value signature.'); } diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 2762ec9c..80254055 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -34,7 +34,7 @@ protected function toJsonRpcResponse(JsonRpcRequest $request, array|Response|Res : ($this->isBinary($item) ? Response::blob($item) : Response::text($item)) ); - $response = ResponseFactory::make($responses->all()); + $response = new ResponseFactory($responses->all()); } $response->responses()->each(function (Response $response) use ($request): void { diff --git a/tests/Fixtures/PromptWithResultMetaPrompt.php b/tests/Fixtures/PromptWithResultMetaPrompt.php index d7b6d6ce..2d1117a7 100644 --- a/tests/Fixtures/PromptWithResultMetaPrompt.php +++ b/tests/Fixtures/PromptWithResultMetaPrompt.php @@ -14,7 +14,7 @@ class PromptWithResultMetaPrompt extends Prompt public function handle(): ResponseFactory { - return ResponseFactory::make( + return Response::make( Response::text('Prompt instructions with result meta')->withMeta(['key' => 'value']) )->withMeta([ 'prompt_version' => '2.0', diff --git a/tests/Fixtures/ResourceWithResultMetaResource.php b/tests/Fixtures/ResourceWithResultMetaResource.php index 96b8e333..75d6cf10 100644 --- a/tests/Fixtures/ResourceWithResultMetaResource.php +++ b/tests/Fixtures/ResourceWithResultMetaResource.php @@ -15,7 +15,7 @@ public function description(): string public function handle(): ResponseFactory { - return ResponseFactory::make( + return Response::make( Response::text('Resource content with result meta') )->withMeta([ 'last_modified' => now()->toIso8601String(), diff --git a/tests/Fixtures/ToolWithBothMetaTool.php b/tests/Fixtures/ToolWithBothMetaTool.php index 0ec55073..22e001d4 100644 --- a/tests/Fixtures/ToolWithBothMetaTool.php +++ b/tests/Fixtures/ToolWithBothMetaTool.php @@ -14,7 +14,7 @@ class ToolWithBothMetaTool extends Tool public function handle(Request $request): ResponseFactory { - return ResponseFactory::make([ + return Response::make([ Response::text('First response')->withMeta(['content_index' => 1]), Response::text('Second response')->withMeta(['content_index' => 2]), ])->withMeta([ diff --git a/tests/Fixtures/ToolWithResultMetaTool.php b/tests/Fixtures/ToolWithResultMetaTool.php index 5ee57457..b5d35bec 100644 --- a/tests/Fixtures/ToolWithResultMetaTool.php +++ b/tests/Fixtures/ToolWithResultMetaTool.php @@ -14,11 +14,10 @@ class ToolWithResultMetaTool extends Tool public function handle(Request $request): ResponseFactory { - return ResponseFactory::make( + return Response::make( Response::text('Tool response with result meta') )->withMeta([ - 'session_id' => $request->sessionId(), - 'timestamp' => now()->toIso8601String(), + 'session_id' => 50, ]); } diff --git a/tests/Unit/Content/BlobTest.php b/tests/Unit/Content/BlobTest.php index ca6b6e51..92546ea5 100644 --- a/tests/Unit/Content/BlobTest.php +++ b/tests/Unit/Content/BlobTest.php @@ -46,7 +46,7 @@ $payload = $blob->toResource($resource); - expect($payload)->toEqual([ + expect($payload)->toMatchArray([ 'blob' => base64_encode('raw-bytes'), 'uri' => 'file://avatar.png', 'name' => 'avatar', @@ -87,7 +87,7 @@ $blob = new Blob('binary-data'); $blob->setMeta(['encoding' => 'base64']); - expect($blob->toArray())->toEqual([ + expect($blob->toArray())->toMatchArray([ 'type' => 'blob', 'blob' => 'binary-data', '_meta' => ['encoding' => 'base64'], @@ -97,7 +97,7 @@ it('does not include meta if null', function (): void { $blob = new Blob('data'); - expect($blob->toArray())->toEqual([ + expect($blob->toArray())->toMatchArray([ 'type' => 'blob', 'blob' => 'data', ]); diff --git a/tests/Unit/Content/NotificationTest.php b/tests/Unit/Content/NotificationTest.php index de986d5a..227114d0 100644 --- a/tests/Unit/Content/NotificationTest.php +++ b/tests/Unit/Content/NotificationTest.php @@ -67,7 +67,7 @@ $notification = new Notification('test/event', ['data' => 'value']); $notification->setMeta(['author' => 'system']); - expect($notification->toArray())->toEqual([ + expect($notification->toArray())->toMatchArray([ 'method' => 'test/event', 'params' => [ 'data' => 'value', @@ -82,7 +82,7 @@ '_meta' => ['source' => 'params'], ]); - expect($notification->toArray())->toEqual([ + expect($notification->toArray())->toMatchArray([ 'method' => 'test/event', 'params' => [ 'data' => 'value', @@ -91,38 +91,8 @@ ]); }); -it('allows both top-level _meta and params _meta independently', function (): void { - $notification = new Notification('test/event', [ - 'data' => 'value', - '_meta' => ['source' => 'params', 'keep' => 'this'], - ]); - $notification->setMeta(['author' => 'system', 'level' => 'top']); - - expect($notification->toArray())->toEqual([ - 'method' => 'test/event', - 'params' => [ - 'data' => 'value', - '_meta' => ['source' => 'params', '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 not set', 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'); + expect($notification->toArray()['params'])->not->toHaveKey('_meta'); }); diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index 6f40a73e..0536e3d2 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -179,18 +179,21 @@ $payload = $response->toArray(); - expect($payload['id'])->toEqual(1) - ->and($payload['result'])->toEqual([ - 'content' => [ - [ - 'type' => 'text', - 'text' => 'Hello, John Doe!', - '_meta' => [ - 'test' => 'metadata', + expect($payload) + ->toMatchArray([ + 'id' => 1, + 'result' => [ + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Hello, John Doe!', + '_meta' => [ + 'test' => 'metadata', + ], ], ], + 'isError' => false, ], - 'isError' => false, ]); }); @@ -265,17 +268,24 @@ $payload = $response->toArray(); - expect($payload['id'])->toEqual(1) - ->and($payload['result'])->toHaveKey('_meta') - ->and($payload['result']['_meta'])->toHaveKey('session_id') - ->and($payload['result']['_meta'])->toHaveKey('timestamp') - ->and($payload['result']['content'])->toEqual([ - [ - 'type' => 'text', - 'text' => 'Tool response with result meta', + expect($payload) + ->toMatchArray([ + 'id' => 1, + 'result' => [ + '_meta' => [ + 'session_id' => 50, + ], + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Tool response with result meta', + ], + ], + 'isError' => false, ], ]) - ->and($payload['result']['isError'])->toBeFalse(); + ->and($payload['result']['_meta']) + ->toHaveKeys(['session_id']); }); it('separates content-level meta from result-level meta', function (): void { @@ -311,19 +321,26 @@ $payload = $response->toArray(); - expect($payload['result'])->toHaveKey('_meta') - ->and($payload['result']['_meta'])->toEqual([ - 'result_key' => 'result_value', - 'total_responses' => 2, - ]) - ->and($payload['result']['content'][0])->toEqual([ - 'type' => 'text', - 'text' => 'First response', - '_meta' => ['content_index' => 1], - ]) - ->and($payload['result']['content'][1])->toEqual([ - 'type' => 'text', - 'text' => 'Second response', - '_meta' => ['content_index' => 2], + expect($payload) + ->toMatchArray([ + 'result' => [ + 'isError' => false, + '_meta' => [ + 'result_key' => 'result_value', + 'total_responses' => 2, + ], + 'content' => [ + [ + 'type' => 'text', + 'text' => 'First response', + '_meta' => ['content_index' => 1], + ], + [ + 'type' => 'text', + 'text' => 'Second response', + '_meta' => ['content_index' => 2], + ], + ], + ], ]); }); diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php index 71fb3302..bf2cedca 100644 --- a/tests/Unit/ResponseFactoryTest.php +++ b/tests/Unit/ResponseFactoryTest.php @@ -5,7 +5,7 @@ it('creates a factory with a single response', function (): void { $response = Response::text('Hello'); - $factory = ResponseFactory::make($response); + $factory = new ResponseFactory($response); expect($factory)->toBeInstanceOf(ResponseFactory::class); expect($factory->responses()) @@ -16,7 +16,7 @@ it('creates a factory with multiple responses', function (): void { $response1 = Response::text('First'); $response2 = Response::text('Second'); - $factory = ResponseFactory::make([$response1, $response2]); + $factory = new ResponseFactory([$response1, $response2]); expect($factory)->toBeInstanceOf(ResponseFactory::class); expect($factory->responses()) @@ -26,14 +26,14 @@ }); it('supports fluent withMeta for result-level metadata', function (): void { - $factory = ResponseFactory::make(Response::text('Hello')) + $factory = (new ResponseFactory(Response::text('Hello'))) ->withMeta(['key' => 'value']); expect($factory->getMeta())->toEqual(['key' => 'value']); }); it('supports withMeta with key-value signature', function (): void { - $factory = ResponseFactory::make(Response::text('Hello')) + $factory = (new ResponseFactory(Response::text('Hello'))) ->withMeta('key1', 'value1') ->withMeta('key2', 'value2'); @@ -41,7 +41,7 @@ }); it('merges multiple withMeta calls', function (): void { - $factory = ResponseFactory::make(Response::text('Hello')) + $factory = (new ResponseFactory(Response::text('Hello'))) ->withMeta(['key1' => 'value1']) ->withMeta(['key2' => 'value1']) ->withMeta(['key2' => 'value2']); @@ -50,20 +50,20 @@ }); it('returns null for getMeta when no meta is set', function (): void { - $factory = ResponseFactory::make(Response::text('Hello')); + $factory = new ResponseFactory(Response::text('Hello')); expect($factory->getMeta())->toBeNull(); }); it('supports Conditionable trait', function (): void { - $factory = ResponseFactory::make(Response::text('Hello')) + $factory = (new ResponseFactory(Response::text('Hello'))) ->when(true, fn ($f): ResponseFactory => $f->withMeta(['conditional' => 'yes'])); expect($factory->getMeta())->toEqual(['conditional' => 'yes']); }); it('supports unless from Conditionable trait', function (): void { - $factory = ResponseFactory::make(Response::text('Hello')) + $factory = (new ResponseFactory(Response::text('Hello'))) ->unless(false, fn ($f): ResponseFactory => $f->withMeta(['unless' => 'applied'])); expect($factory->getMeta())->toEqual(['unless' => 'applied']); @@ -71,46 +71,16 @@ it('separates content-level meta from result-level meta', function (): void { $response = Response::text('Hello')->withMeta(['content_meta' => 'content_value']); - $factory = ResponseFactory::make($response) + $factory = (new ResponseFactory($response)) ->withMeta(['result_meta' => 'result_value']); expect($factory->getMeta())->toEqual(['result_meta' => 'result_value']); expect($factory->responses()->first())->toBe($response); }); -it('throws exception when array contains non-Response object', function (): void { - expect(fn (): ResponseFactory => ResponseFactory::make([ - Response::text('Valid'), - 'Invalid string', - ]))->toThrow( - InvalidArgumentException::class, - ); -}); - -it('throws exception when array contains nested ResponseFactory', function (): void { - $nestedFactory = ResponseFactory::make(Response::text('Nested')); - - expect(fn (): ResponseFactory => ResponseFactory::make([ - Response::text('First'), - $nestedFactory, - Response::text('Third'), - ]))->toThrow( - InvalidArgumentException::class, - ); -}); - -it('throws exception when an array contains null', function (): void { - expect(fn (): ResponseFactory => ResponseFactory::make([ - Response::text('Valid'), - null, - ]))->toThrow( - InvalidArgumentException::class, - ); -}); - it('accepts a single Response without validation error', function (): void { $response = Response::text('Single response'); - $factory = ResponseFactory::make($response); + $factory = new ResponseFactory($response); expect($factory->responses()) ->toHaveCount(1) @@ -124,7 +94,7 @@ Response::blob('binary'), ]; - $factory = ResponseFactory::make($responses); + $factory = new ResponseFactory($responses); expect($factory->responses()) ->toHaveCount(3) diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 3d2cda73..6f206248 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -5,6 +5,7 @@ use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Content\Blob; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; @@ -146,3 +147,33 @@ ->and($response->content()->toArray()['params'])->toHaveKey('_meta') ->and($response->content()->toArray()['params']['_meta'])->toEqual(['author' => 'system']); }); + +it('throws exception when array contains a non-Response object', function (): void { + expect(fn (): ResponseFactory => Response::make([ + Response::text('Valid'), + 'Invalid string', + ]))->toThrow( + InvalidArgumentException::class, + ); +}); + +it('throws exception when array contains nested ResponseFactory', function (): void { + $nestedFactory = Response::make(Response::text('Nested')); + + expect(fn (): ResponseFactory => Response::make([ + Response::text('First'), + $nestedFactory, + Response::text('Third'), + ]))->toThrow( + InvalidArgumentException::class, + ); +}); + +it('throws exception when an array contains null', function (): void { + expect(fn (): ResponseFactory => Response::make([ + Response::text('Valid'), + null, + ]))->toThrow( + InvalidArgumentException::class, + ); +}); From f73917a34b1c447a63eb62b3a8c8f690e1820cd4 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 14 Nov 2025 17:02:45 +0530 Subject: [PATCH 10/14] Refactor --- src/ResponseFactory.php | 2 +- tests/Fixtures/PromptWithResultMetaPrompt.php | 2 +- tests/Unit/Methods/GetPromptTest.php | 2 +- tests/Unit/ResponseFactoryTest.php | 14 -------------- 4 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index 7fab8012..b80efc75 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -19,7 +19,7 @@ class ResponseFactory /** * @var Collection */ - public Collection $responses; + protected Collection $responses; /** * @param Response|array $responses diff --git a/tests/Fixtures/PromptWithResultMetaPrompt.php b/tests/Fixtures/PromptWithResultMetaPrompt.php index 2d1117a7..4ee21472 100644 --- a/tests/Fixtures/PromptWithResultMetaPrompt.php +++ b/tests/Fixtures/PromptWithResultMetaPrompt.php @@ -18,7 +18,7 @@ public function handle(): ResponseFactory Response::text('Prompt instructions with result meta')->withMeta(['key' => 'value']) )->withMeta([ 'prompt_version' => '2.0', - 'last_updated' => now()->toDateString(), + 'last_updated' => '2025-01-01', ]); } } diff --git a/tests/Unit/Methods/GetPromptTest.php b/tests/Unit/Methods/GetPromptTest.php index 56597b82..f086b97b 100644 --- a/tests/Unit/Methods/GetPromptTest.php +++ b/tests/Unit/Methods/GetPromptTest.php @@ -232,7 +232,7 @@ ->result->toMatchArray([ '_meta' => [ 'prompt_version' => '2.0', - 'last_updated' => now()->toDateString(), + 'last_updated' => '2025-01-01', ], 'description' => 'Prompt with result-level meta', 'messages' => [ diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php index bf2cedca..8188897a 100644 --- a/tests/Unit/ResponseFactoryTest.php +++ b/tests/Unit/ResponseFactoryTest.php @@ -86,17 +86,3 @@ ->toHaveCount(1) ->first()->toBe($response); }); - -it('accepts array of valid Response objects', function (): void { - $responses = [ - Response::text('First'), - Response::text('Second'), - Response::blob('binary'), - ]; - - $factory = new ResponseFactory($responses); - - expect($factory->responses()) - ->toHaveCount(3) - ->all()->toBe($responses); -}); From 178274f623b694050e9ce1fb8407a5a0a134df86 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 14 Nov 2025 19:06:29 +0530 Subject: [PATCH 11/14] Refactor --- src/Response.php | 12 ----- src/ResponseFactory.php | 13 +++++- .../Concerns/InteractsWithResponses.php | 46 ++++++++++++------- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/Response.php b/src/Response.php index 3627314a..f573f5b9 100644 --- a/src/Response.php +++ b/src/Response.php @@ -6,7 +6,6 @@ use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; -use InvalidArgumentException; use JsonException; use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; @@ -74,17 +73,6 @@ public function content(): Content */ public static function make(Response|array $responses): ResponseFactory { - if (is_array($responses)) { - foreach ($responses as $index => $response) { - // @phpstan-ignore instanceof.alwaysTrue - if (! $response instanceof Response) { - throw new InvalidArgumentException( - "Invalid response type at index {$index}: Expected ".Response::class.', but received '.get_debug_type($response).'.' - ); - } - } - } - return new ResponseFactory($responses); } diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index b80efc75..12b89b29 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use Laravel\Mcp\Server\Concerns\HasMeta; class ResponseFactory @@ -26,7 +27,17 @@ class ResponseFactory */ public function __construct(Response|array $responses) { - $this->responses = collect(Arr::wrap($responses)); + $wrapped = Arr::wrap($responses); + + foreach ($wrapped as $index => $response) { + if (! $response instanceof Response) { + throw new InvalidArgumentException( + "Invalid response type at index {$index}: Expected ".Response::class.', but received '.get_debug_type($response).'.' + ); + } + } + + $this->responses = collect($wrapped); } /** diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index 80254055..e9314ba7 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -19,25 +19,12 @@ trait InteractsWithResponses { /** * @param array|Response|ResponseFactory|string $response - * - * @throws JsonRpcException */ protected function toJsonRpcResponse(JsonRpcRequest $request, array|Response|ResponseFactory|string $response, callable $serializable): JsonRpcResponse { - if (is_array($response) && count($response) === 1) { - $response = Arr::first($response); - } - - if (! ($response instanceof ResponseFactory)) { - $responses = collect(Arr::wrap($response))->map(fn ($item): Response => $item instanceof Response - ? $item - : ($this->isBinary($item) ? Response::blob($item) : Response::text($item)) - ); - - $response = new ResponseFactory($responses->all()); - } + $responseFactory = $this->toResponseFactory($response); - $response->responses()->each(function (Response $response) use ($request): void { + $responseFactory->responses()->each(function (Response $response) use ($request): void { if (! $this instanceof Errable && $response->isError()) { throw new JsonRpcException( $response->content()->__toString(), // @phpstan-ignore-line @@ -47,7 +34,7 @@ protected function toJsonRpcResponse(JsonRpcRequest $request, array|Response|Res } }); - return JsonRpcResponse::result($request->id, $serializable($response)); + return JsonRpcResponse::result($request->id, $serializable($responseFactory)); } /** @@ -89,4 +76,31 @@ protected function isBinary(string $content): bool { return str_contains($content, "\0"); } + + /** + * @param array|Response|ResponseFactory|string $response + */ + private function toResponseFactory(array|Response|ResponseFactory|string $response): ResponseFactory + { + $responseFactory = is_array($response) && count($response) === 1 + ? Arr::first($response) + : $response; + + if ($responseFactory instanceof ResponseFactory) { + return $responseFactory; + } + + $responses = collect(Arr::wrap($responseFactory)) + ->map(function ($item): mixed { + if ($item instanceof Response) { + return $item; + } + + return $this->isBinary($item) + ? Response::blob($item) + : Response::text($item); + }); + + return new ResponseFactory($responses->all()); + } } From 56c786fbf4b1f4fa12540afbf34e72376105c955 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 14 Nov 2025 19:22:32 +0530 Subject: [PATCH 12/14] Update method signatures --- src/Response.php | 4 ++-- src/Server/Concerns/HasMeta.php | 4 ++-- src/Server/Contracts/Content.php | 4 ++-- src/Server/Methods/Concerns/InteractsWithResponses.php | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Response.php b/src/Response.php index f573f5b9..35cbae06 100644 --- a/src/Response.php +++ b/src/Response.php @@ -77,9 +77,9 @@ public static function make(Response|array $responses): ResponseFactory } /** - * @param string|array $meta + * @param array|string $meta */ - public function withMeta(string|array $meta, mixed $value = null): static + public function withMeta(array|string $meta, mixed $value = null): static { $this->content->setMeta($meta, $value); diff --git a/src/Server/Concerns/HasMeta.php b/src/Server/Concerns/HasMeta.php index 282dd2d1..43c83015 100644 --- a/src/Server/Concerns/HasMeta.php +++ b/src/Server/Concerns/HasMeta.php @@ -14,9 +14,9 @@ trait HasMeta protected ?array $meta = null; /** - * @param string|array $meta + * @param array|string $meta */ - public function setMeta(string|array $meta, mixed $value = null): void + public function setMeta(array|string $meta, mixed $value = null): void { $this->meta ??= []; diff --git a/src/Server/Contracts/Content.php b/src/Server/Contracts/Content.php index 65c45dae..264075c4 100644 --- a/src/Server/Contracts/Content.php +++ b/src/Server/Contracts/Content.php @@ -31,9 +31,9 @@ public function toPrompt(Prompt $prompt): array; public function toResource(Resource $resource): array; /** - * @param string|array $meta + * @param array|string $meta */ - public function setMeta(string|array $meta, mixed $value = null): void; + public function setMeta(array|string $meta, mixed $value = null): void; public function __toString(): string; } diff --git a/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/Server/Methods/Concerns/InteractsWithResponses.php index e9314ba7..75e8b9fb 100644 --- a/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -20,7 +20,7 @@ trait InteractsWithResponses /** * @param array|Response|ResponseFactory|string $response */ - protected function toJsonRpcResponse(JsonRpcRequest $request, array|Response|ResponseFactory|string $response, callable $serializable): JsonRpcResponse + protected function toJsonRpcResponse(JsonRpcRequest $request, Response|ResponseFactory|array|string $response, callable $serializable): JsonRpcResponse { $responseFactory = $this->toResponseFactory($response); @@ -80,7 +80,7 @@ protected function isBinary(string $content): bool /** * @param array|Response|ResponseFactory|string $response */ - private function toResponseFactory(array|Response|ResponseFactory|string $response): ResponseFactory + private function toResponseFactory(Response|ResponseFactory|array|string $response): ResponseFactory { $responseFactory = is_array($response) && count($response) === 1 ? Arr::first($response) From 4eee2860eb9c7221f862684e447ddb51399c6e9c Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 14 Nov 2025 19:40:53 +0530 Subject: [PATCH 13/14] Update Test --- .../ResourceWithResultMetaResource.php | 4 ++- tests/Fixtures/SayHiWithMetaTool.php | 2 ++ tests/Fixtures/ToolWithBothMetaTool.php | 2 ++ tests/Fixtures/ToolWithResultMetaTool.php | 2 ++ tests/Unit/Methods/ReadResourceTest.php | 26 ++++++++++++------- tests/Unit/Prompts/PromptTest.php | 15 ++++++----- tests/Unit/Resources/ResourceTest.php | 10 ++++--- 7 files changed, 40 insertions(+), 21 deletions(-) diff --git a/tests/Fixtures/ResourceWithResultMetaResource.php b/tests/Fixtures/ResourceWithResultMetaResource.php index 75d6cf10..b7460b0e 100644 --- a/tests/Fixtures/ResourceWithResultMetaResource.php +++ b/tests/Fixtures/ResourceWithResultMetaResource.php @@ -1,5 +1,7 @@ withMeta([ - 'last_modified' => now()->toIso8601String(), + 'last_modified' => '2025-01-01', 'version' => '1.0', ]); } diff --git a/tests/Fixtures/SayHiWithMetaTool.php b/tests/Fixtures/SayHiWithMetaTool.php index 48fb7148..8e0b4e1f 100644 --- a/tests/Fixtures/SayHiWithMetaTool.php +++ b/tests/Fixtures/SayHiWithMetaTool.php @@ -1,5 +1,7 @@ toArray(); expect($payload['id'])->toEqual(1) - ->and($payload['result'])->toHaveKey('_meta') - ->and($payload['result']['_meta'])->toHaveKey('last_modified') - ->and($payload['result']['_meta'])->toHaveKey('version') - ->and($payload['result']['_meta']['version'])->toBe('1.0') - ->and($payload['result']['contents'][0])->toEqual([ - 'text' => 'Resource content with result meta', - 'uri' => 'file://resources/with-result-meta.txt', - 'name' => 'resource-with-result-meta-resource', - 'title' => 'Resource With Result Meta Resource', - 'mimeType' => 'text/plain', + ->and($payload)->toMatchArray([ + 'result' => [ + '_meta' => [ + 'last_modified' => '2025-01-01', + 'version' => '1.0', + ], + 'contents' => [ + [ + 'text' => 'Resource content with result meta', + 'uri' => 'file://resources/with-result-meta.txt', + 'name' => 'resource-with-result-meta-resource', + 'title' => 'Resource With Result Meta Resource', + 'mimeType' => 'text/plain', + ], + ], + ], ]); }); diff --git a/tests/Unit/Prompts/PromptTest.php b/tests/Unit/Prompts/PromptTest.php index ccab64d7..93571933 100644 --- a/tests/Unit/Prompts/PromptTest.php +++ b/tests/Unit/Prompts/PromptTest.php @@ -41,10 +41,12 @@ public function handle(): Response } }; - expect($prompt->toArray()['_meta'])->toEqual([ - 'category' => 'greeting', - 'tags' => ['hello', 'welcome'], - ]); + expect($prompt->toArray()) + ->toHaveKey('_meta') + ->_meta->toEqual([ + 'category' => 'greeting', + 'tags' => ['hello', 'welcome'], + ]); }); it('includes meta in array representation with other fields', function (): void { @@ -81,7 +83,8 @@ public function arguments(): array ->toHaveKey('description', 'A friendly greeting') ->toHaveKey('arguments') ->toHaveKey('_meta') - ->and($array['_meta'])->toEqual(['version' => '1.0']) - ->and($array['arguments'])->toHaveCount(1); + ->and($array) + ->_meta->toEqual(['version' => '1.0']) + ->arguments->toHaveCount(1); }); diff --git a/tests/Unit/Resources/ResourceTest.php b/tests/Unit/Resources/ResourceTest.php index 35888430..b4b3e439 100644 --- a/tests/Unit/Resources/ResourceTest.php +++ b/tests/Unit/Resources/ResourceTest.php @@ -179,8 +179,10 @@ public function handle(): string } }; - expect($resource->toArray()['_meta'])->toEqual([ - 'author' => 'John Doe', - 'version' => '1.0', - ]); + expect($resource->toArray()) + ->toHaveKey('_meta') + ->_meta->toEqual([ + 'author' => 'John Doe', + 'version' => '1.0', + ]); }); From 3b93915c81065bf1bcec21ae4572294c32aaf7a0 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 14 Nov 2025 20:29:36 +0530 Subject: [PATCH 14/14] Improve testing --- tests/Unit/ResponseFactoryTest.php | 19 ++----------------- tests/Unit/Tools/ToolTest.php | 5 ----- tests/Unit/Transport/JsonRpcRequestTest.php | 6 ++---- 3 files changed, 4 insertions(+), 26 deletions(-) diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php index 8188897a..55b67407 100644 --- a/tests/Unit/ResponseFactoryTest.php +++ b/tests/Unit/ResponseFactoryTest.php @@ -49,12 +49,6 @@ expect($factory->getMeta())->toEqual(['key1' => 'value1', 'key2' => 'value2']); }); -it('returns null for getMeta when no meta is set', function (): void { - $factory = new ResponseFactory(Response::text('Hello')); - - expect($factory->getMeta())->toBeNull(); -}); - it('supports Conditionable trait', function (): void { $factory = (new ResponseFactory(Response::text('Hello'))) ->when(true, fn ($f): ResponseFactory => $f->withMeta(['conditional' => 'yes'])); @@ -74,15 +68,6 @@ $factory = (new ResponseFactory($response)) ->withMeta(['result_meta' => 'result_value']); - expect($factory->getMeta())->toEqual(['result_meta' => 'result_value']); - expect($factory->responses()->first())->toBe($response); -}); - -it('accepts a single Response without validation error', function (): void { - $response = Response::text('Single response'); - $factory = new ResponseFactory($response); - - expect($factory->responses()) - ->toHaveCount(1) - ->first()->toBe($response); + expect($factory->getMeta())->toEqual(['result_meta' => 'result_value']) + ->and($factory->responses()->first())->toBe($response); }); diff --git a/tests/Unit/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index ae29aa71..c83cd313 100644 --- a/tests/Unit/Tools/ToolTest.php +++ b/tests/Unit/Tools/ToolTest.php @@ -94,11 +94,6 @@ ->and($array['inputSchema']['required'])->toEqual(['message']); }); -it('returns no meta by default', function (): void { - $tool = new TestTool; - expect($tool->meta())->toBeNull(); -}); - it('can have custom meta', function (): void { $tool = new CustomMetaTool; expect($tool->toArray()['_meta'])->toEqual(['key' => 'value']); diff --git a/tests/Unit/Transport/JsonRpcRequestTest.php b/tests/Unit/Transport/JsonRpcRequestTest.php index cf036d82..3ceae3f5 100644 --- a/tests/Unit/Transport/JsonRpcRequestTest.php +++ b/tests/Unit/Transport/JsonRpcRequestTest.php @@ -128,14 +128,12 @@ ], ]); - expect($request->params['_meta'] ?? null)->toEqual([ + expect($request->meta())->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 { @@ -148,7 +146,7 @@ ], ]); - expect($request->params['_meta'] ?? null)->toBeNull(); + expect($request->meta())->toBeNull(); }); it('passes meta to Request object', function (): void {