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 a64457e8..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; @@ -68,6 +69,35 @@ public function content(): Content return $this->content; } + /** + * @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 $meta, mixed $value = null): static + { + $this->content->setMeta($meta, $value); + + return $this; + } + /** * @throws NotImplementedException */ diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php new file mode 100644 index 00000000..7fab8012 --- /dev/null +++ b/src/ResponseFactory.php @@ -0,0 +1,57 @@ + + */ + public Collection $responses; + + /** + * @param Response|array $responses + */ + public function __construct(Response|array $responses) + { + $this->responses = collect(Arr::wrap($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 new file mode 100644 index 00000000..282dd2d1 --- /dev/null +++ b/src/Server/Concerns/HasMeta.php @@ -0,0 +1,48 @@ +|null + */ + protected ?array $meta = null; + + /** + * @param string|array $meta + */ + public function setMeta(string|array $meta, mixed $value = null): void + { + $this->meta ??= []; + + if (! is_array($meta)) { + if (is_null($value)) { + throw new InvalidArgumentException('Value is required when using key-value signature.'); + } + + $this->meta[$meta] = $value; + + return; + } + + $this->meta = array_merge($this->meta, $meta); + } + + /** + * @template T of array + * + * @param T $baseArray + * @return T&array{_meta?: array} + */ + public function mergeMeta(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 5f04a1d4..f7b9f714 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\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -12,6 +13,8 @@ class Blob implements Content { + use HasMeta; + public function __construct(protected string $content) { // @@ -42,13 +45,13 @@ public function toPrompt(Prompt $prompt): array */ public function toResource(Resource $resource): array { - return [ + return $this->mergeMeta([ 'blob' => base64_encode($this->content), 'uri' => $resource->uri(), 'name' => $resource->name(), 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), - ]; + ]); } public function __toString(): string @@ -61,9 +64,9 @@ public function __toString(): string */ public function toArray(): array { - return [ + return $this->mergeMeta([ 'type' => 'blob', 'blob' => $this->content, - ]; + ]); } } diff --git a/src/Server/Content/Notification.php b/src/Server/Content/Notification.php index 53006291..8d20566b 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\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -11,6 +12,8 @@ class Notification implements Content { + use HasMeta; + /** * @param array $params */ @@ -53,9 +56,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..602d9bdb 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\Concerns\HasMeta; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -11,6 +12,8 @@ class Text implements Content { + use HasMeta; + public function __construct(protected string $text) { // @@ -37,13 +40,13 @@ public function toPrompt(Prompt $prompt): array */ public function toResource(Resource $resource): array { - return [ + return $this->mergeMeta([ 'text' => $this->text, 'uri' => $resource->uri(), 'name' => $resource->name(), 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), - ]; + ]); } public function __toString(): string @@ -56,9 +59,9 @@ public function __toString(): string */ public function toArray(): array { - return [ + return $this->mergeMeta([ 'type' => 'text', 'text' => $this->text, - ]; + ]); } } diff --git a/src/Server/Contracts/Content.php b/src/Server/Contracts/Content.php index fa70daf9..65c45dae 100644 --- a/src/Server/Contracts/Content.php +++ b/src/Server/Contracts/Content.php @@ -30,5 +30,10 @@ public function toPrompt(Prompt $prompt): array; */ public function toResource(Resource $resource): array; + /** + * @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/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..80254055 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,45 @@ 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 (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()); + } - $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/Primitive.php b/src/Server/Primitive.php index 7a3adb12..c2b517f5 100644 --- a/src/Server/Primitive.php +++ b/src/Server/Primitive.php @@ -7,12 +7,15 @@ 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 = ''; @@ -40,6 +43,14 @@ public function description(): string : $this->description; } + /** + * @return array|null + */ + public function meta(): ?array + { + return $this->meta; + } + public function eligibleForRegistration(): bool { if (method_exists($this, 'shouldRegister')) { diff --git a/src/Server/Prompt.php b/src/Server/Prompt.php index 1e5dbd9e..eb7daa68 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->mergeMeta([ '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..b65492fd 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->mergeMeta([ '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 a9568aff..0019841e 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -51,24 +51,28 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, - * annotations?: array|object + * annotations?: array|object, + * _meta?: array * } */ public function toArray(): array { $annotations = $this->annotations(); + $schema = JsonSchema::object( $this->schema(...), )->toArray(); $schema['properties'] ??= (object) []; - return [ + // @phpstan-ignore return.type + return $this->mergeMeta([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), 'inputSchema' => $schema, 'annotations' => $annotations === [] ? (object) [] : $annotations, - ]; + ]); + } } diff --git a/src/Server/Transport/JsonRpcRequest.php b/src/Server/Transport/JsonRpcRequest.php index 1ee32878..9d1ebe43 100644 --- a/src/Server/Transport/JsonRpcRequest.php +++ b/src/Server/Transport/JsonRpcRequest.php @@ -60,8 +60,16 @@ public function get(string $key, mixed $default = null): mixed return $this->params[$key] ?? $default; } + /** + * @return array|null + */ + public function meta(): ?array + { + 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/PromptWithResultMetaPrompt.php b/tests/Fixtures/PromptWithResultMetaPrompt.php new file mode 100644 index 00000000..2d1117a7 --- /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..75d6cf10 --- /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/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/Fixtures/ToolWithBothMetaTool.php b/tests/Fixtures/ToolWithBothMetaTool.php new file mode 100644 index 00000000..22e001d4 --- /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..b5d35bec --- /dev/null +++ b/tests/Fixtures/ToolWithResultMetaTool.php @@ -0,0 +1,28 @@ +withMeta([ + 'session_id' => 50, + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Unit/Content/BlobTest.php b/tests/Unit/Content/BlobTest.php index 7d7823e2..92546ea5 100644 --- a/tests/Unit/Content/BlobTest.php +++ b/tests/Unit/Content/BlobTest.php @@ -29,6 +29,33 @@ ]); }); +it('preserves meta when converting to a resource payload', function (): void { + $blob = new Blob('raw-bytes'); + $blob->setMeta(['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)->toMatchArray([ + '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 +82,23 @@ 'blob' => 'bytes', ]); }); + +it('supports meta via setMeta', function (): void { + $blob = new Blob('binary-data'); + $blob->setMeta(['encoding' => 'base64']); + + expect($blob->toArray())->toMatchArray([ + 'type' => 'blob', + 'blob' => 'binary-data', + '_meta' => ['encoding' => 'base64'], + ]); +}); + +it('does not include meta if null', function (): void { + $blob = new Blob('data'); + + expect($blob->toArray())->toMatchArray([ + 'type' => 'blob', + 'blob' => 'data', + ]); +}); diff --git a/tests/Unit/Content/NotificationTest.php b/tests/Unit/Content/NotificationTest.php index 0a2ad155..227114d0 100644 --- a/tests/Unit/Content/NotificationTest.php +++ b/tests/Unit/Content/NotificationTest.php @@ -62,3 +62,37 @@ 'params' => ['x' => 1, 'y' => 2], ]); }); + +it('supports _meta via setMeta', function (): void { + $notification = new Notification('test/event', ['data' => 'value']); + $notification->setMeta(['author' => 'system']); + + expect($notification->toArray())->toMatchArray([ + 'method' => 'test/event', + 'params' => [ + 'data' => 'value', + '_meta' => ['author' => 'system'], + ], + ]); +}); + +it('supports _meta in params', function (): void { + $notification = new Notification('test/event', [ + 'data' => 'value', + '_meta' => ['source' => 'params'], + ]); + + expect($notification->toArray())->toMatchArray([ + 'method' => 'test/event', + 'params' => [ + 'data' => 'value', + '_meta' => ['source' => 'params'], + ], + ]); +}); + +it('does not include _meta if not set', function (): void { + $notification = new Notification('test/event', ['data' => 'value']); + + expect($notification->toArray()['params'])->not->toHaveKey('_meta'); +}); diff --git a/tests/Unit/Content/TextTest.php b/tests/Unit/Content/TextTest.php index ade98a93..95a0f6e1 100644 --- a/tests/Unit/Content/TextTest.php +++ b/tests/Unit/Content/TextTest.php @@ -29,6 +29,33 @@ ]); }); +it('preserves meta when converting to a resource payload', function (): void { + $text = new Text('Hello world'); + $text->setMeta(['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 +92,23 @@ 'text' => 'abc', ]); }); + +it('supports meta via setMeta', function (): void { + $text = new Text('Hello'); + $text->setMeta(['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..0536e3d2 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -7,6 +7,9 @@ use Tests\Fixtures\CurrentTimeTool; 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([ @@ -145,6 +148,55 @@ ]); }); +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) + ->toMatchArray([ + 'id' => 1, + 'result' => [ + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Hello, John Doe!', + '_meta' => [ + 'test' => 'metadata', + ], + ], + ], + 'isError' => false, + ], + ]); +}); + it('may resolve dependencies out of the container', function (): void { $request = JsonRpcRequest::from([ 'jsonrpc' => '2.0', @@ -182,3 +234,113 @@ ->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) + ->toMatchArray([ + 'id' => 1, + 'result' => [ + '_meta' => [ + 'session_id' => 50, + ], + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Tool response with result meta', + ], + ], + 'isError' => false, + ], + ]) + ->and($payload['result']['_meta']) + ->toHaveKeys(['session_id']); +}); + +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) + ->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/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/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/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/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/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php new file mode 100644 index 00000000..bf2cedca --- /dev/null +++ b/tests/Unit/ResponseFactoryTest.php @@ -0,0 +1,102 @@ +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 = new ResponseFactory([$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 = (new ResponseFactory(Response::text('Hello'))) + ->withMeta(['key' => 'value']); + + expect($factory->getMeta())->toEqual(['key' => 'value']); +}); + +it('supports withMeta with key-value signature', function (): void { + $factory = (new ResponseFactory(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 = (new ResponseFactory(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 = 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'])); + + expect($factory->getMeta())->toEqual(['conditional' => 'yes']); +}); + +it('supports unless from Conditionable trait', function (): void { + $factory = (new ResponseFactory(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 = (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); +}); + +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); +}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index d9484d7a..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; @@ -122,3 +123,57 @@ $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')->withMeta(['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')->withMeta(['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 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()['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, + ); +}); diff --git a/tests/Unit/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index c30c158d..ae29aa71 100644 --- a/tests/Unit/Tools/ToolTest.php +++ b/tests/Unit/Tools/ToolTest.php @@ -94,6 +94,16 @@ ->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']); +}); + class TestTool extends Tool { public function description(): string @@ -155,3 +165,10 @@ public function schema(\Illuminate\JsonSchema\JsonSchema $schema): array ]; } } + +class CustomMetaTool extends TestTool +{ + protected ?array $meta = [ + 'key' => 'value', + ]; +} 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'); +});