From 564fc27be4329635ab7d646e53f744e6c09e9973 Mon Sep 17 00:00:00 2001 From: zacksmash Date: Thu, 6 Nov 2025 07:48:34 -0700 Subject: [PATCH 1/2] Add App response type --- src/Enums/OpenAI.php | 24 +++++++ src/Response.php | 13 ++++ src/Server/Content/App.php | 117 ++++++++++++++++++++++++++++++ tests/Unit/Content/AppTest.php | 128 +++++++++++++++++++++++++++++++++ tests/Unit/ResponseTest.php | 14 ++++ 5 files changed, 296 insertions(+) create mode 100644 src/Enums/OpenAI.php create mode 100644 src/Server/Content/App.php create mode 100644 tests/Unit/Content/AppTest.php diff --git a/src/Enums/OpenAI.php b/src/Enums/OpenAI.php new file mode 100644 index 0000000..defe03f --- /dev/null +++ b/src/Enums/OpenAI.php @@ -0,0 +1,24 @@ +render() : $view; + + $app = new App($view); + + return new static( + $config ? $config($app) : $app + ); + } + public static function error(string $text): static { return new static(new Text($text), isError: true); diff --git a/src/Server/Content/App.php b/src/Server/Content/App.php new file mode 100644 index 0000000..512d952 --- /dev/null +++ b/src/Server/Content/App.php @@ -0,0 +1,117 @@ + + */ + protected array $meta = []; + + public function __construct( + protected string $text, + ) {} + + /** + * @return array + */ + public function toTool(Tool $tool): array + { + throw new Exception('App should only be used from a Resource.'); + } + + /** + * @return array + */ + public function toPrompt(Prompt $prompt): array + { + 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 ($meta is null ? array : self) + */ + public function meta(?array $meta = null): self|array + { + if (is_null($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; + } + + /** + * @param array $value + */ + 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; + } + + public function __toString(): string + { + return $this->text; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'type' => 'text', + 'text' => $this->text, + ]; + } +} diff --git a/tests/Unit/Content/AppTest.php b/tests/Unit/Content/AppTest.php new file mode 100644 index 0000000..52e85d2 --- /dev/null +++ b/tests/Unit/Content/AppTest.php @@ -0,0 +1,128 @@ +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'))->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'; + + 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, + OpenAI::WIDGET_DESCRIPTION->value => 'A simple text widget', + OpenAI::WIDGET_CSP->value => [ + 'default-src' => "'self'", + ], + OpenAI::WIDGET_DOMAIN->value => 'example.com', + ], + ]); +}); + +it('may be used in tools', function (): void { + $text = new App('Run me'); + + $payload = $text->toTool(new class extends Tool {}); +})->throws(Exception::class); + +it('may be used in prompts', function (): void { + $text = new App('Say hi'); + + $payload = $text->toPrompt(new class extends Prompt {}); +})->throws(Exception::class); + +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', + ]); +}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index d9484d7..42c9b8d 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; @@ -122,3 +124,15 @@ $content = $response->content(); expect((string) $content)->toBe(json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); }); + +it('creates an app response', function (): void { + $response = Response::app('
', fn (App $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); +}); From 92024b6eb429fe094888dc4e7958387d3f545b4b Mon Sep 17 00:00:00 2001 From: zacksmash Date: Thu, 6 Nov 2025 08:15:07 -0700 Subject: [PATCH 2/2] Add more tests for helper methods --- tests/Unit/Content/AppTest.php | 123 +++++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Content/AppTest.php b/tests/Unit/Content/AppTest.php index 52e85d2..7927988 100644 --- a/tests/Unit/Content/AppTest.php +++ b/tests/Unit/Content/AppTest.php @@ -2,7 +2,6 @@ use Laravel\Mcp\Enums\OpenAI; use Laravel\Mcp\Server\Content\App; -use Laravel\Mcp\Server\Content\Text; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; use Laravel\Mcp\Server\Tool; @@ -61,6 +60,122 @@ ]); }); +it('can use the prefersBorder meta helper', 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('can use the widgetDescription meta helper', function (): void { + $text = (new App('Hello world'))->widgetDescription('A simple text widget'); + + $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_DESCRIPTION->value => 'A simple text widget', + ], + ]); +}); + +it('can use the widgetCSP meta helper', function (): void { + $text = (new App('Hello world'))->widgetCSP([ + 'default-src' => "'self'", + ]); + + $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_CSP->value => [ + 'default-src' => "'self'", + ], + ], + ]); +}); + +it('can use the widgetDomain meta helper', function (): void { + $text = (new App('Hello world'))->widgetDomain('example.com'); + + $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_DOMAIN->value => 'example.com', + ], + ]); +}); + it('may use helper methods to assign meta info', function (): void { $text = (new App('Hello world')) ->prefersBorder() @@ -100,13 +215,13 @@ ]); }); -it('may be used in tools', function (): void { +it('may not be used in tools', function (): void { $text = new App('Run me'); $payload = $text->toTool(new class extends Tool {}); })->throws(Exception::class); -it('may be used in prompts', function (): void { +it('may not be used in prompts', function (): void { $text = new App('Say hi'); $payload = $text->toPrompt(new class extends Prompt {}); @@ -119,7 +234,7 @@ }); it('converts to array with type and text', function (): void { - $text = new Text('abc'); + $text = new App('abc'); expect($text->toArray())->toEqual([ 'type' => 'text',