From 46b3cac839530bf3657ae53231f0801138b34414 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 13:10:55 -0600 Subject: [PATCH 01/21] Add Meta to Primitive and return it in Tools List call. --- src/Server/Primitive.php | 7 +++++++ src/Server/Tool.php | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Server/Primitive.php b/src/Server/Primitive.php index 7a3adb1..356b1b8 100644 --- a/src/Server/Primitive.php +++ b/src/Server/Primitive.php @@ -19,6 +19,8 @@ abstract class Primitive implements Arrayable protected string $description = ''; + protected array $meta = []; + public function name(): string { return $this->name === '' @@ -40,6 +42,11 @@ public function description(): string : $this->description; } + public function meta(): array + { + return is_array($this->meta) ? $this->meta : []; + } + public function eligibleForRegistration(): bool { if (method_exists($this, 'shouldRegister')) { diff --git a/src/Server/Tool.php b/src/Server/Tool.php index 110411a..0827060 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -58,7 +58,7 @@ public function toArray(): array { $annotations = $this->annotations(); - return [ + return array_filter([ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), @@ -66,6 +66,7 @@ public function toArray(): array $this->schema(...), )->toArray(), 'annotations' => $annotations === [] ? (object) [] : $annotations, - ]; + '_meta' => filled($this->meta) ? $this->meta : null, + ], fn ($value) => $value !== null); } } From 34aa9191f121ec24b8ebbdb9369f2213429db05f Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 13:38:12 -0600 Subject: [PATCH 02/21] Update meta property call --- 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 0827060..d6f4f9d 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -66,7 +66,7 @@ public function toArray(): array $this->schema(...), )->toArray(), 'annotations' => $annotations === [] ? (object) [] : $annotations, - '_meta' => filled($this->meta) ? $this->meta : null, + '_meta' => filled($this->meta()) ? $this->meta() : null, ], fn ($value) => $value !== null); } } From 37c26d090a1079b5352c0a43e890be5492e8968e Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 13:50:12 -0600 Subject: [PATCH 03/21] Handle Tool meta and structured content --- src/Response.php | 26 ++++++++++++++++++++++++++ src/Server/Methods/CallTool.php | 6 ++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Response.php b/src/Response.php index a64457e..5c70c1f 100644 --- a/src/Response.php +++ b/src/Response.php @@ -19,6 +19,10 @@ class Response use Conditionable; use Macroable; + protected array $meta = []; + + protected array $structured_content = []; + protected function __construct( protected Content $content, protected Role $role = Role::USER, @@ -103,4 +107,26 @@ public function role(): Role { return $this->role; } + + public function meta(?array $meta = null): array|self + { + if (empty($meta)) { + return $this->meta; + } + + $this->meta = array_merge($this->meta, $meta ?? []); + + return $this; + } + + public function structuredContent(?array $structuredContent = null): array|self + { + if (empty($structuredContent)) { + return $this->structured_content; + } + + $this->structured_content = array_merge($this->structured_content, $structuredContent ?? []); + + return $this; + } } diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index e65bad3..6a27c68 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -65,9 +65,11 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat */ protected function serializable(Tool $tool): callable { - return fn (Collection $responses): array => [ + return fn (Collection $responses): array => array_filter([ + '_meta' => $responses->flatMap(fn (Response $response): array => $response->meta()), 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), + 'structuredContent' => $responses->flatMap(fn (Response $response): array => $response->structuredContent()), 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), - ]; + ], fn ($value): bool => filled($value)); } } From aa040a9c00a348f53efd32a1a654ef1198ad245e Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 14:05:52 -0600 Subject: [PATCH 04/21] Add resource meta through App() response method, and OpenAI Enum --- src/Enums/OpenAI.php | 22 ++++++++ src/Response.php | 13 +++++ src/Server/Content/App.php | 106 +++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 src/Enums/OpenAI.php create mode 100644 src/Server/Content/App.php diff --git a/src/Enums/OpenAI.php b/src/Enums/OpenAI.php new file mode 100644 index 0000000..a60a15a --- /dev/null +++ b/src/Enums/OpenAI.php @@ -0,0 +1,22 @@ +render() : $view; + + $app = new App($view); + + return new static( + $config ? $config($app) : $app + ); + } + /** * @internal * diff --git a/src/Server/Content/App.php b/src/Server/Content/App.php new file mode 100644 index 0000000..6954a37 --- /dev/null +++ b/src/Server/Content/App.php @@ -0,0 +1,106 @@ + + */ + public function toTool(Tool $tool): array + { + return $this->toArray(); + } + + /** + * @return array + */ + public function toPrompt(Prompt $prompt): array + { + return $this->toArray(); + } + + public function meta(?array $meta = null): self|array + { + if (empty($this->meta)) { + return $this->meta; + } + + $this->meta = array_merge($this->meta, $meta ?? []); + + return $this; + } + + public function prefersBorder(bool $value = true): self + { + $this->meta[OpenAI::WIDGET_PREFERS_BORDER->value] = $value; + + return $this; + } + + public function widgetDescription(string $value): self + { + $this->meta[OpenAI::WIDGET_DESCRIPTION->value] = $value; + + return $this; + } + + public function widgetCSP(array $value): self + { + $this->meta[OpenAI::WIDGET_CSP->value] = $value; + + return $this; + } + + public function widgetDomain(string $value): self + { + $this->meta[OpenAI::WIDGET_DOMAIN->value] = $value; + + return $this; + } + + /** + * @return array + */ + public function toResource(Resource $resource): array + { + return array_filter([ + 'text' => $this->text, + 'uri' => $resource->uri(), + 'name' => $resource->name(), + 'title' => $resource->title(), + 'mimeType' => $resource->mimeType(), + '_meta' => $this->meta, + ], fn ($value) => filled($value)); + } + + public function __toString(): string + { + return $this->text; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'type' => 'text', + 'text' => $this->text, + ]; + } +} From 0203e802cc2a0cc1a3575620c86d5c735345a5a2 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 14:33:44 -0600 Subject: [PATCH 05/21] =?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/Response.php | 4 ++-- src/Server/Content/App.php | 4 ++-- src/Server/Methods/CallTool.php | 2 +- src/Server/Tool.php | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Response.php b/src/Response.php index f989ef7..a5729db 100644 --- a/src/Response.php +++ b/src/Response.php @@ -123,7 +123,7 @@ public function role(): Role public function meta(?array $meta = null): array|self { - if (empty($meta)) { + if (blank($meta)) { return $this->meta; } @@ -134,7 +134,7 @@ public function meta(?array $meta = null): array|self public function structuredContent(?array $structuredContent = null): array|self { - if (empty($structuredContent)) { + if (blank($structuredContent)) { return $this->structured_content; } diff --git a/src/Server/Content/App.php b/src/Server/Content/App.php index 6954a37..0263213 100644 --- a/src/Server/Content/App.php +++ b/src/Server/Content/App.php @@ -36,7 +36,7 @@ public function toPrompt(Prompt $prompt): array public function meta(?array $meta = null): self|array { - if (empty($this->meta)) { + if (blank($meta)) { return $this->meta; } @@ -85,7 +85,7 @@ public function toResource(Resource $resource): array 'title' => $resource->title(), 'mimeType' => $resource->mimeType(), '_meta' => $this->meta, - ], fn ($value) => filled($value)); + ], filled(...)); } public function __toString(): string diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index 6a27c68..0cd8cdd 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -70,6 +70,6 @@ protected function serializable(Tool $tool): callable 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), 'structuredContent' => $responses->flatMap(fn (Response $response): array => $response->structuredContent()), 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), - ], fn ($value): bool => filled($value)); + ], filled(...)); } } diff --git a/src/Server/Tool.php b/src/Server/Tool.php index d6f4f9d..c3a1600 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -67,6 +67,6 @@ public function toArray(): array )->toArray(), 'annotations' => $annotations === [] ? (object) [] : $annotations, '_meta' => filled($this->meta()) ? $this->meta() : null, - ], fn ($value) => $value !== null); + ], filled(...)); } } From 1e01cddfef95afd0bb3ada3c16379ce683e563a7 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 14:49:39 -0600 Subject: [PATCH 06/21] Fix some rector items --- src/Response.php | 18 ++++++++++++++++-- src/Server/Content/App.php | 12 +++++++++++- src/Server/Methods/CallTool.php | 2 +- src/Server/Primitive.php | 8 +++++++- src/Server/Tool.php | 3 ++- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/Response.php b/src/Response.php index a5729db..1ac4ce4 100644 --- a/src/Response.php +++ b/src/Response.php @@ -21,8 +21,14 @@ class Response use Conditionable; use Macroable; + /** + * @var array + */ protected array $meta = []; + /** + * @var array + */ protected array $structured_content = []; protected function __construct( @@ -121,24 +127,32 @@ public function role(): Role return $this->role; } + /** + * @param array|null $meta + * @return array|self + */ public function meta(?array $meta = null): array|self { if (blank($meta)) { return $this->meta; } - $this->meta = array_merge($this->meta, $meta ?? []); + $this->meta = array_merge($this->meta, $meta); return $this; } + /** + * @param array|null $structuredContent + * @return array|self + */ public function structuredContent(?array $structuredContent = null): array|self { if (blank($structuredContent)) { return $this->structured_content; } - $this->structured_content = array_merge($this->structured_content, $structuredContent ?? []); + $this->structured_content = array_merge($this->structured_content, $structuredContent); return $this; } diff --git a/src/Server/Content/App.php b/src/Server/Content/App.php index 0263213..98a9ae9 100644 --- a/src/Server/Content/App.php +++ b/src/Server/Content/App.php @@ -12,6 +12,9 @@ class App implements Content { + /** + * @var array + */ protected array $meta = []; public function __construct( @@ -34,13 +37,17 @@ public function toPrompt(Prompt $prompt): array return $this->toArray(); } + /** + * @param array|null $meta + * @return array|self + */ public function meta(?array $meta = null): self|array { if (blank($meta)) { return $this->meta; } - $this->meta = array_merge($this->meta, $meta ?? []); + $this->meta = array_merge($this->meta, $meta); return $this; } @@ -59,6 +66,9 @@ public function widgetDescription(string $value): self return $this; } + /** + * @param array $value + */ public function widgetCSP(array $value): self { $this->meta[OpenAI::WIDGET_CSP->value] = $value; diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index 0cd8cdd..1609a5c 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -61,7 +61,7 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{content: array>, isError: bool} + * @return callable(Collection): array{_meta: array, content: array>, structuredContent: array>, isError: bool} */ protected function serializable(Tool $tool): callable { diff --git a/src/Server/Primitive.php b/src/Server/Primitive.php index 356b1b8..97c08e5 100644 --- a/src/Server/Primitive.php +++ b/src/Server/Primitive.php @@ -19,6 +19,9 @@ abstract class Primitive implements Arrayable protected string $description = ''; + /** + * @var array + */ protected array $meta = []; public function name(): string @@ -42,9 +45,12 @@ public function description(): string : $this->description; } + /** + * @return array + */ public function meta(): array { - return is_array($this->meta) ? $this->meta : []; + return $this->meta; } public function eligibleForRegistration(): bool diff --git a/src/Server/Tool.php b/src/Server/Tool.php index c3a1600..03ddc34 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -51,7 +51,8 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, - * annotations?: array|object + * annotations?: array|object, + * _meta?: array, * } */ public function toArray(): array From cf299b7e6680f8b5cb6ebf0004c44b68f4ad9a27 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 14:55:57 -0600 Subject: [PATCH 07/21] Add strict types to enum --- src/Enums/OpenAI.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Enums/OpenAI.php b/src/Enums/OpenAI.php index a60a15a..defe03f 100644 --- a/src/Enums/OpenAI.php +++ b/src/Enums/OpenAI.php @@ -1,5 +1,7 @@ Date: Mon, 27 Oct 2025 15:13:49 -0600 Subject: [PATCH 08/21] Add test --- src/Response.php | 2 +- tests/Unit/Content/AppTest.php | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Content/AppTest.php diff --git a/src/Response.php b/src/Response.php index 1ac4ce4..a5ae885 100644 --- a/src/Response.php +++ b/src/Response.php @@ -52,7 +52,7 @@ public static function text(string $text): static return new static(new Text($text)); } - public static function app(string|View $view, ?callable $config): static + public static function app(string|View $view, ?callable $config = null): static { $view = $view instanceof View ? $view->render() : $view; diff --git a/tests/Unit/Content/AppTest.php b/tests/Unit/Content/AppTest.php new file mode 100644 index 0000000..859c84f --- /dev/null +++ b/tests/Unit/Content/AppTest.php @@ -0,0 +1,96 @@ +toResource($resource); + + expect($payload)->toEqual([ + 'text' => 'Hello world', + 'uri' => 'file://readme.txt', + 'name' => 'readme', + 'title' => 'Readme File', + 'mimeType' => 'text/plain', + ]); +}); + +it('it can configure meta information for the app', function (): void { + $text = new App('Hello world')->prefersBorder(); + $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' => [ + OpenAI::WIDGET_PREFERS_BORDER->value => true, + ], + ]); +}); + +it('may be used in tools', function (): void { + $text = new App('Run me'); + + $payload = $text->toTool(new class extends Tool {}); + + expect($payload)->toEqual([ + 'type' => 'text', + 'text' => 'Run me', + ]); +}); + +it('may be used in prompts', function (): void { + $text = new App('Say hi'); + + $payload = $text->toPrompt(new class extends Prompt {}); + + expect($payload)->toEqual([ + 'type' => 'text', + 'text' => 'Say hi', + ]); +}); + +it('casts to string as raw text', function (): void { + $text = new App('plain'); + + expect((string) $text)->toBe('plain'); +}); + +it('converts to array with type and text', function (): void { + $text = new Text('abc'); + + expect($text->toArray())->toEqual([ + 'type' => 'text', + 'text' => 'abc', + ]); +}); From a75b019b7edd5e9ca0a501fff056b82646883eea Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 15:19:31 -0600 Subject: [PATCH 09/21] Fix PHP <8.3 error --- tests/Unit/Content/AppTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Content/AppTest.php b/tests/Unit/Content/AppTest.php index 859c84f..1e25f60 100644 --- a/tests/Unit/Content/AppTest.php +++ b/tests/Unit/Content/AppTest.php @@ -32,7 +32,7 @@ }); it('it can configure meta information for the app', function (): void { - $text = new App('Hello world')->prefersBorder(); + $text = (new App('Hello world'))->prefersBorder(); $resource = new class extends Resource { protected string $uri = 'file://readme.txt'; From fb7164fc52348e1baeb7a9f0d142c947c3b93857 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 15:57:48 -0600 Subject: [PATCH 10/21] Added more tests --- tests/Unit/ResponseTest.php | 14 ++++++++++++++ tests/Unit/Tools/ToolTest.php | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index d9484d7..7a82d65 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use Laravel\Mcp\Enums\OpenAI; use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Content\App; use Laravel\Mcp\Server\Content\Blob; use Laravel\Mcp\Server\Content\Notification; use Laravel\Mcp\Server\Content\Text; @@ -27,6 +29,18 @@ expect($response->role())->toBe(Role::USER); }); +it('creates an app response', function (): void { + $response = Response::app('
', fn (App $app) => $app->prefersBorder()); + + expect($response->content()->meta())->toEqual([ + OpenAI::WIDGET_PREFERS_BORDER->value => true, + ]); + expect($response->content())->toBeInstanceOf(App::class); + expect($response->isNotification())->toBeFalse(); + expect($response->isError())->toBeFalse(); + expect($response->role())->toBe(Role::USER); +}); + it('creates a blob response', function (): void { $response = Response::blob('binary content'); diff --git a/tests/Unit/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index 11f7a8a..224bc6b 100644 --- a/tests/Unit/Tools/ToolTest.php +++ b/tests/Unit/Tools/ToolTest.php @@ -28,6 +28,16 @@ expect($tool->toArray()['title'])->toBe('Custom Title Tool'); }); +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 be read only', function (): void { $tool = new ReadOnlyTool; $annotations = $tool->annotations(); @@ -90,6 +100,13 @@ class CustomTitleTool extends TestTool protected string $title = 'Custom Title Tool'; } +class CustomMetaTool extends TestTool +{ + protected array $meta = [ + 'key' => 'value', + ]; +} + #[IsReadOnly] class ReadOnlyTool extends TestTool {} From d83a8c0c0969158eeb61e0251fba9287fe4eadff Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 16:02:24 -0600 Subject: [PATCH 11/21] Add more test coverage to App content class --- tests/Unit/Content/AppTest.php | 44 +++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Content/AppTest.php b/tests/Unit/Content/AppTest.php index 1e25f60..8f26f20 100644 --- a/tests/Unit/Content/AppTest.php +++ b/tests/Unit/Content/AppTest.php @@ -32,7 +32,44 @@ }); it('it can configure meta information for the app', function (): void { - $text = (new App('Hello world'))->prefersBorder(); + $text = (new App('Hello world'))->meta([ + 'foo' => 'bar', + ]); + + $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' => [ + 'foo' => 'bar', + ], + ]); +}); + +it('may use helper methods to assign meta info', function (): void { + $text = (new App('Hello world')) + ->prefersBorder() + ->widgetDescription('A simple text widget') + ->widgetCSP([ + 'default-src' => "'self'", + ]) + ->widgetDomain('example.com'); + $resource = new class extends Resource { protected string $uri = 'file://readme.txt'; @@ -54,6 +91,11 @@ 'mimeType' => 'text/plain', '_meta' => [ OpenAI::WIDGET_PREFERS_BORDER->value => true, + OpenAI::WIDGET_DESCRIPTION->value => 'A simple text widget', + OpenAI::WIDGET_CSP->value => [ + 'default-src' => "'self'", + ], + OpenAI::WIDGET_DOMAIN->value => 'example.com', ], ]); }); From 80deeb7487247da4260e1fc4d3f8841fea3bbdc2 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 16:06:06 -0600 Subject: [PATCH 12/21] Update test --- tests/Unit/ResponseTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 7a82d65..c08690e 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -30,7 +30,7 @@ }); it('creates an app response', function (): void { - $response = Response::app('
', fn (App $app) => $app->prefersBorder()); + $response = Response::app('
', fn (App $app): App => $app->prefersBorder()); expect($response->content()->meta())->toEqual([ OpenAI::WIDGET_PREFERS_BORDER->value => true, From 5de7e83ca80ba06c879958ea155c2a20490e579d Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 16:12:27 -0600 Subject: [PATCH 13/21] Add more test coverage --- tests/Unit/ResponseTest.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index c08690e..09ea4b6 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -136,3 +136,31 @@ $content = $response->content(); expect((string) $content)->toBe(json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); }); + +it('handles adding meta to a text response', function (): void { + $response = Response::text('Hello world') + ->meta(['key1' => 'value1', 'key2' => 2]); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->isNotification())->toBeFalse(); + expect($response->isError())->toBeFalse(); + expect($response->role())->toBe(Role::USER); + expect($response->meta())->toEqual(['key1' => 'value1', 'key2' => 2]); +}); + +it('handles adding structured content to a text response', function (): void { + $response = Response::text('Hello world') + ->structuredContent([ + 'section1' => ['item1', 'item2'], + 'section2' => ['item3', 'item4'], + ]); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->isNotification())->toBeFalse(); + expect($response->isError())->toBeFalse(); + expect($response->role())->toBe(Role::USER); + expect($response->structuredContent())->toEqual([ + 'section1' => ['item1', 'item2'], + 'section2' => ['item3', 'item4'], + ]); +}); From 950c4208ab9725ef5ee83a0d5f284efe9ffb1914 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 16:37:22 -0600 Subject: [PATCH 14/21] Fix phpstan errors --- src/Server/Methods/CallTool.php | 16 +++++++++++++--- src/Server/Tool.php | 24 ++++++++++++++---------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index 1609a5c..ff6b8d4 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -61,14 +61,24 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{_meta: array, content: array>, structuredContent: array>, isError: bool} + * @return callable(Collection): array{_meta?: array, content?: array>, structuredContent?: array, isError?: bool} */ protected function serializable(Tool $tool): callable { return fn (Collection $responses): array => array_filter([ - '_meta' => $responses->flatMap(fn (Response $response): array => $response->meta()), + '_meta' => $responses->flatMap(function (Response $response): array { + /** @var array $meta */ + $meta = $response->meta(); + + return $meta; + })->all(), 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), - 'structuredContent' => $responses->flatMap(fn (Response $response): array => $response->structuredContent()), + 'structuredContent' => $responses->flatMap(function (Response $response): array { + /** @var array $structuredContent */ + $structuredContent = $response->structuredContent(); + + return $structuredContent; + })->all(), 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), ], filled(...)); } diff --git a/src/Server/Tool.php b/src/Server/Tool.php index 03ddc34..0458209 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -59,15 +59,19 @@ public function toArray(): array { $annotations = $this->annotations(); - return array_filter([ - 'name' => $this->name(), - 'title' => $this->title(), - 'description' => $this->description(), - 'inputSchema' => JsonSchema::object( - $this->schema(...), - )->toArray(), - 'annotations' => $annotations === [] ? (object) [] : $annotations, - '_meta' => filled($this->meta()) ? $this->meta() : null, - ], filled(...)); + return array_merge( + [ + 'name' => $this->name(), + 'title' => $this->title(), + 'description' => $this->description(), + 'inputSchema' => JsonSchema::object( + $this->schema(...), + )->toArray(), + 'annotations' => $annotations === [] ? (object) [] : $annotations, + ], + array_filter([ + '_meta' => filled($this->meta()) ? $this->meta() : null, + ], filled(...)) + ); } } From 5c6b9de2d11c790556a0d92532bacb6744c89434 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 20:55:25 -0600 Subject: [PATCH 15/21] =?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/Response.php | 8 +++---- src/Server/Content/App.php | 39 +++++++++++++++++---------------- src/Server/Methods/CallTool.php | 21 +++++++----------- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/Response.php b/src/Response.php index a5ae885..250e1ce 100644 --- a/src/Response.php +++ b/src/Response.php @@ -129,11 +129,11 @@ public function role(): Role /** * @param array|null $meta - * @return array|self + * @return ($meta is null ? array : self) */ public function meta(?array $meta = null): array|self { - if (blank($meta)) { + if (is_null($meta)) { return $this->meta; } @@ -144,11 +144,11 @@ public function meta(?array $meta = null): array|self /** * @param array|null $structuredContent - * @return array|self + * @return ($structuredContent is null ? array : self) */ public function structuredContent(?array $structuredContent = null): array|self { - if (blank($structuredContent)) { + if (is_null($structuredContent)) { return $this->structured_content; } diff --git a/src/Server/Content/App.php b/src/Server/Content/App.php index 98a9ae9..512d952 100644 --- a/src/Server/Content/App.php +++ b/src/Server/Content/App.php @@ -4,6 +4,7 @@ namespace Laravel\Mcp\Server\Content; +use Exception; use Laravel\Mcp\Enums\OpenAI; use Laravel\Mcp\Server\Contracts\Content; use Laravel\Mcp\Server\Prompt; @@ -26,7 +27,7 @@ public function __construct( */ public function toTool(Tool $tool): array { - return $this->toArray(); + throw new Exception('App should only be used from a Resource.'); } /** @@ -34,16 +35,31 @@ public function toTool(Tool $tool): array */ public function toPrompt(Prompt $prompt): array { - return $this->toArray(); + throw new Exception('App should only be used from a Resource.'); + } + + /** + * @return array + */ + public function toResource(Resource $resource): array + { + return array_filter([ + 'text' => $this->text, + 'uri' => $resource->uri(), + 'name' => $resource->name(), + 'title' => $resource->title(), + 'mimeType' => $resource->mimeType(), + '_meta' => $this->meta, + ], filled(...)); } /** * @param array|null $meta - * @return array|self + * @return ($meta is null ? array : self) */ public function meta(?array $meta = null): self|array { - if (blank($meta)) { + if (is_null($meta)) { return $this->meta; } @@ -83,21 +99,6 @@ public function widgetDomain(string $value): self return $this; } - /** - * @return array - */ - public function toResource(Resource $resource): array - { - return array_filter([ - 'text' => $this->text, - 'uri' => $resource->uri(), - 'name' => $resource->name(), - 'title' => $resource->title(), - 'mimeType' => $resource->mimeType(), - '_meta' => $this->meta, - ], filled(...)); - } - public function __toString(): string { return $this->text; diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index ff6b8d4..367e93c 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -61,25 +61,20 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{_meta?: array, content?: array>, structuredContent?: array, isError?: bool} + * @return callable(Collection):array{ + * _meta?: array, + * content?: array>, + * structuredContent?: array, + * isError?: bool + * } */ protected function serializable(Tool $tool): callable { return fn (Collection $responses): array => array_filter([ - '_meta' => $responses->flatMap(function (Response $response): array { - /** @var array $meta */ - $meta = $response->meta(); - - return $meta; - })->all(), 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), - 'structuredContent' => $responses->flatMap(function (Response $response): array { - /** @var array $structuredContent */ - $structuredContent = $response->structuredContent(); - - return $structuredContent; - })->all(), 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), + 'structuredContent' => $responses->flatMap(fn (Response $response): array => $response->structuredContent())->all(), + '_meta' => $responses->flatMap(fn (Response $response): array => $response->meta())->all(), ], filled(...)); } } From b2b44c56d03068cc0cd863e8db7ab90f5473578d Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 20:56:35 -0600 Subject: [PATCH 16/21] =?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/Methods/CallTool.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index 367e93c..99b5d8a 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -64,17 +64,17 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat * @return callable(Collection):array{ * _meta?: array, * content?: array>, - * structuredContent?: array, * isError?: bool + * structuredContent?: array, * } */ protected function serializable(Tool $tool): callable { return fn (Collection $responses): array => array_filter([ + '_meta' => $responses->flatMap(fn (Response $response): array => $response->meta())->all(), 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), 'structuredContent' => $responses->flatMap(fn (Response $response): array => $response->structuredContent())->all(), - '_meta' => $responses->flatMap(fn (Response $response): array => $response->meta())->all(), ], filled(...)); } } From ca0c30ee13ad57be57f9ff613856585b12b1deea Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 21:02:20 -0600 Subject: [PATCH 17/21] =?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/Methods/CallTool.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index 99b5d8a..ef4c88d 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -64,7 +64,7 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat * @return callable(Collection):array{ * _meta?: array, * content?: array>, - * isError?: bool + * isError?: bool, * structuredContent?: array, * } */ From 57e7a1522765f00f1e97fafb8995c9d2b76cef89 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 27 Oct 2025 21:08:34 -0600 Subject: [PATCH 18/21] =?UTF-8?q?WIP=20=F0=9F=9A=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Unit/Content/AppTest.php | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/tests/Unit/Content/AppTest.php b/tests/Unit/Content/AppTest.php index 8f26f20..52e85d2 100644 --- a/tests/Unit/Content/AppTest.php +++ b/tests/Unit/Content/AppTest.php @@ -104,23 +104,13 @@ $text = new App('Run me'); $payload = $text->toTool(new class extends Tool {}); - - expect($payload)->toEqual([ - 'type' => 'text', - 'text' => 'Run me', - ]); -}); +})->throws(Exception::class); it('may be used in prompts', function (): void { $text = new App('Say hi'); $payload = $text->toPrompt(new class extends Prompt {}); - - expect($payload)->toEqual([ - 'type' => 'text', - 'text' => 'Say hi', - ]); -}); +})->throws(Exception::class); it('casts to string as raw text', function (): void { $text = new App('plain'); From 868cbcb9fa442bb145bdc6604f82b078b80b0b0f Mon Sep 17 00:00:00 2001 From: zacksmash Date: Tue, 28 Oct 2025 10:44:13 -0600 Subject: [PATCH 19/21] Add securitySchemes to tools --- src/Server/Tool.php | 13 ++ src/Support/SecurityScheme.php | 153 ++++++++++++++++++++++ tests/Unit/Support/SecuritySchemeTest.php | 80 +++++++++++ tests/Unit/Tools/ToolTest.php | 18 +++ 4 files changed, 264 insertions(+) create mode 100644 src/Support/SecurityScheme.php create mode 100644 tests/Unit/Support/SecuritySchemeTest.php diff --git a/src/Server/Tool.php b/src/Server/Tool.php index 0458209..e64d5a7 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -6,6 +6,7 @@ use Illuminate\JsonSchema\JsonSchema; use Laravel\Mcp\Server\Contracts\Tools\Annotation; +use Laravel\Mcp\Support\SecurityScheme; use ReflectionAttribute; use ReflectionClass; @@ -19,6 +20,14 @@ public function schema(JsonSchema $schema): array return []; } + /** + * @return array + */ + public function securitySchemes(SecurityScheme $scheme): array + { + return []; + } + /** * @return array */ @@ -51,6 +60,7 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, + * securitySchemes?: array, * annotations?: array|object, * _meta?: array, * } @@ -70,6 +80,9 @@ public function toArray(): array 'annotations' => $annotations === [] ? (object) [] : $annotations, ], array_filter([ + 'securitySchemes' => SecurityScheme::object( + $this->securitySchemes(...), + ), '_meta' => filled($this->meta()) ? $this->meta() : null, ], filled(...)) ); diff --git a/src/Support/SecurityScheme.php b/src/Support/SecurityScheme.php new file mode 100644 index 0000000..0981d04 --- /dev/null +++ b/src/Support/SecurityScheme.php @@ -0,0 +1,153 @@ + */ + 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 object(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 = new self; + $instance->type = 'oauth2'; + + if ($scopes !== []) { + $instance->scopes(...$scopes); + } + + return $instance; + } + + /** + * @return array + */ + public static function apiKey(string $name = 'api_key', string $in = 'header'): array + { + return [ + 'type' => 'apiKey', + 'name' => $name, + 'in' => $in, + ]; + } + + /** + * @return array + */ + public static function bearer(string $format = 'JWT'): array + { + return [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => $format, + ]; + } + + /** + * @return array + */ + public static function basic(): array + { + return [ + 'type' => 'http', + 'scheme' => 'basic', + ]; + } + + /** + * @return array + */ + public function __invoke(): array + { + return $this->toArray(); + } +} diff --git a/tests/Unit/Support/SecuritySchemeTest.php b/tests/Unit/Support/SecuritySchemeTest.php new file mode 100644 index 0000000..a8b59de --- /dev/null +++ b/tests/Unit/Support/SecuritySchemeTest.php @@ -0,0 +1,80 @@ +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', + ]); +}); diff --git a/tests/Unit/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index 224bc6b..aa13d3e 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; @@ -38,6 +39,13 @@ 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']], + ]); +}); + it('can be read only', function (): void { $tool = new ReadOnlyTool; $annotations = $tool->annotations(); @@ -107,6 +115,16 @@ class CustomMetaTool extends TestTool ]; } +class SecuritySchemesTool extends TestTool +{ + public function securitySchemes(SecurityScheme $scheme): array + { + return [ + $scheme::oauth2('read', 'write'), + ]; + } +} + #[IsReadOnly] class ReadOnlyTool extends TestTool {} From dbe19d2f4d0794d3338d6b3d6bad334b4c796b9c Mon Sep 17 00:00:00 2001 From: zacksmash Date: Wed, 29 Oct 2025 16:03:01 -0600 Subject: [PATCH 20/21] Update SecurityScheme class --- src/Server/Tool.php | 2 +- src/Support/SecurityScheme.php | 7 +++--- tests/Unit/Support/SecuritySchemeTest.php | 29 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/Server/Tool.php b/src/Server/Tool.php index e64d5a7..e46b89b 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -80,7 +80,7 @@ public function toArray(): array 'annotations' => $annotations === [] ? (object) [] : $annotations, ], array_filter([ - 'securitySchemes' => SecurityScheme::object( + 'securitySchemes' => SecurityScheme::make( $this->securitySchemes(...), ), '_meta' => filled($this->meta()) ? $this->meta() : null, diff --git a/src/Support/SecurityScheme.php b/src/Support/SecurityScheme.php index 0981d04..92e38af 100644 --- a/src/Support/SecurityScheme.php +++ b/src/Support/SecurityScheme.php @@ -34,14 +34,14 @@ protected function setType(string $type): self * @param (Closure(SecurityScheme): array>)|array> $schemes * @return array> */ - public static function object(Closure|array $schemes = []): 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 + fn ($scheme) => $scheme instanceof self ? $scheme->toArray() : $scheme ); return $result->toArray(); @@ -98,8 +98,7 @@ public static function noauth(): array */ public static function oauth2(string|array ...$scopes): self { - $instance = new self; - $instance->type = 'oauth2'; + $instance = self::type('oauth2'); if ($scopes !== []) { $instance->scopes(...$scopes); diff --git a/tests/Unit/Support/SecuritySchemeTest.php b/tests/Unit/Support/SecuritySchemeTest.php index a8b59de..c37ace9 100644 --- a/tests/Unit/Support/SecuritySchemeTest.php +++ b/tests/Unit/Support/SecuritySchemeTest.php @@ -78,3 +78,32 @@ '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', + ], + ]); +}); From 7e85f8313deda022510e130312bc363c3a1ee066 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Mon, 3 Nov 2025 08:37:10 -0700 Subject: [PATCH 21/21] Rearrange meta output for consistency --- src/Server/Methods/CallTool.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index ef4c88d..fc1f975 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -71,10 +71,10 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat protected function serializable(Tool $tool): callable { return fn (Collection $responses): array => array_filter([ - '_meta' => $responses->flatMap(fn (Response $response): array => $response->meta())->all(), 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), 'structuredContent' => $responses->flatMap(fn (Response $response): array => $response->structuredContent())->all(), + '_meta' => $responses->flatMap(fn (Response $response): array => $response->meta())->all(), ], filled(...)); } }