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 @@ + + */ + protected array $meta = []; + + /** + * @var array + */ + protected array $structured_content = []; + protected function __construct( protected Content $content, protected Role $role = Role::USER, @@ -40,6 +52,17 @@ public static function text(string $text): static return new static(new Text($text)); } + public static function app(string|View $view, ?callable $config = null): static + { + $view = $view instanceof View ? $view->render() : $view; + + $app = new App($view); + + return new static( + $config ? $config($app) : $app + ); + } + /** * @internal * @@ -103,4 +126,34 @@ public function role(): Role { return $this->role; } + + /** + * @param array|null $meta + * @return ($meta is null ? array : self) + */ + public function meta(?array $meta = null): array|self + { + if (is_null($meta)) { + return $this->meta; + } + + $this->meta = array_merge($this->meta, $meta); + + return $this; + } + + /** + * @param array|null $structuredContent + * @return ($structuredContent is null ? array : self) + */ + public function structuredContent(?array $structuredContent = null): array|self + { + if (is_null($structuredContent)) { + return $this->structured_content; + } + + $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 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/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index e65bad3..fc1f975 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -61,13 +61,20 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{content: array>, isError: bool} + * @return callable(Collection):array{ + * _meta?: array, + * content?: array>, + * isError?: bool, + * structuredContent?: array, + * } */ protected function serializable(Tool $tool): callable { - return fn (Collection $responses): array => [ + return fn (Collection $responses): array => array_filter([ '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(...)); } } diff --git a/src/Server/Primitive.php b/src/Server/Primitive.php index 7a3adb1..97c08e5 100644 --- a/src/Server/Primitive.php +++ b/src/Server/Primitive.php @@ -19,6 +19,11 @@ abstract class Primitive implements Arrayable protected string $description = ''; + /** + * @var array + */ + protected array $meta = []; + public function name(): string { return $this->name === '' @@ -40,6 +45,14 @@ public function description(): string : $this->description; } + /** + * @return array + */ + public function meta(): array + { + return $this->meta; + } + public function eligibleForRegistration(): bool { if (method_exists($this, 'shouldRegister')) { diff --git a/src/Server/Tool.php b/src/Server/Tool.php index a9568af..9352190 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,7 +60,9 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, - * annotations?: array|object + * securitySchemes?: array, + * annotations?: array|object, + * _meta?: array, * } */ public function toArray(): array @@ -63,12 +74,20 @@ public function toArray(): array $schema['properties'] ??= (object) []; - return [ - 'name' => $this->name(), - 'title' => $this->title(), - 'description' => $this->description(), - 'inputSchema' => $schema, - 'annotations' => $annotations === [] ? (object) [] : $annotations, - ]; + return array_merge( + [ + 'name' => $this->name(), + 'title' => $this->title(), + 'description' => $this->description(), + 'inputSchema' => $schema, + 'annotations' => $annotations === [] ? (object) [] : $annotations, + ], + array_filter([ + 'securitySchemes' => SecurityScheme::make( + $this->securitySchemes(...), + ), + '_meta' => filled($this->meta()) ? $this->meta() : null, + ], filled(...)) + ); } } diff --git a/src/Support/SecurityScheme.php b/src/Support/SecurityScheme.php new file mode 100644 index 0000000..92e38af --- /dev/null +++ b/src/Support/SecurityScheme.php @@ -0,0 +1,152 @@ + */ + protected array $scopes = []; + + /** @var array */ + protected array $additionalData = []; + + private function __construct(string $type = '') + { + if ($type !== '') { + $this->type = $type; + } + } + + protected function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + /** + * @param (Closure(SecurityScheme): array>)|array> $schemes + * @return array> + */ + public static function make(Closure|array $schemes = []): array + { + if ($schemes instanceof Closure) { + $schemes = $schemes(new self); + } + + $result = collect($schemes)->map( + fn ($scheme) => $scheme instanceof self ? $scheme->toArray() : $scheme + ); + + return $result->toArray(); + } + + /** + * @param string|array ...$scopes + */ + public function scopes(string|array ...$scopes): self + { + $this->scopes = collect($scopes) + ->flatten() + ->toArray(); + + return $this; + } + + public function with(string $key, mixed $value): self + { + $this->additionalData[$key] = $value; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + $scheme = array_merge(['type' => $this->type], $this->additionalData); + + if ($this->scopes !== []) { + $scheme['scopes'] = $this->scopes; + } + + return $scheme; + } + + public static function type(string $type): self + { + return new self($type); + } + + /** + * @return array + */ + public static function noauth(): array + { + return ['type' => 'noauth']; + } + + /** + * @param string|array ...$scopes + */ + public static function oauth2(string|array ...$scopes): self + { + $instance = self::type('oauth2'); + + if ($scopes !== []) { + $instance->scopes(...$scopes); + } + + return $instance; + } + + /** + * @return array + */ + public static function apiKey(string $name = 'api_key', string $in = 'header'): array + { + return [ + 'type' => 'apiKey', + 'name' => $name, + 'in' => $in, + ]; + } + + /** + * @return array + */ + public static function bearer(string $format = 'JWT'): array + { + return [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => $format, + ]; + } + + /** + * @return array + */ + public static function basic(): array + { + return [ + 'type' => 'http', + 'scheme' => 'basic', + ]; + } + + /** + * @return array + */ + public function __invoke(): array + { + return $this->toArray(); + } +} diff --git a/tests/Unit/Content/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..09ea4b6 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 => $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'); @@ -122,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'], + ]); +}); diff --git a/tests/Unit/Support/SecuritySchemeTest.php b/tests/Unit/Support/SecuritySchemeTest.php new file mode 100644 index 0000000..c37ace9 --- /dev/null +++ b/tests/Unit/Support/SecuritySchemeTest.php @@ -0,0 +1,109 @@ +with('flows', [ + 'authorizationCode' => [ + 'authorizationUrl' => 'https://example.com/oauth/authorize', + 'tokenUrl' => 'https://example.com/oauth/token', + ], + ]); + + expect($scheme->toArray())->toBe([ + 'type' => 'oauth2', + 'flows' => [ + 'authorizationCode' => [ + 'authorizationUrl' => 'https://example.com/oauth/authorize', + 'tokenUrl' => 'https://example.com/oauth/token', + ], + ], + 'scopes' => ['read', 'write'], + ]); +}); + +it('can set scopes', function (): void { + $scheme = SecurityScheme::oauth2() + ->scopes('read', 'write', 'delete'); + + expect($scheme->toArray()['scopes'])->toBe(['read', 'write', 'delete']); +}); + +it('can set an oauth tyoe', function (): void { + $scheme = SecurityScheme::oauth2(); + + expect($scheme->toArray()['type'])->toBe('oauth2'); +}); + +it('can set a noauth type', function (): void { + $scheme = SecurityScheme::noauth(); + + expect($scheme)->toBe([ + 'type' => 'noauth', + ]); +}); + +it('can set a type', function (): void { + $scheme = SecurityScheme::type('apiKey'); + + expect($scheme->toArray()['type'])->toBe('apiKey'); +}); + +it('can set an apiKey auth', function (): void { + $scheme = SecurityScheme::apiKey('X-API-KEY', 'header'); + + expect($scheme)->toBe([ + 'type' => 'apiKey', + 'name' => 'X-API-KEY', + 'in' => 'header', + ]); +}); + +it('can set a bearer auth', function (): void { + $scheme = SecurityScheme::bearer('JWT'); + + expect($scheme)->toBe([ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ]); +}); + +it('can set a basic auth', function (): void { + $scheme = SecurityScheme::basic(); + + expect($scheme)->toBe([ + 'type' => 'http', + 'scheme' => 'basic', + ]); +}); + +it('can make a set of schemes', function (): void { + $schemes = SecurityScheme::make([ + SecurityScheme::basic(), + SecurityScheme::bearer('JWT'), + [ + 'type' => 'apiKey', + 'name' => 'X-API-KEY', + 'in' => 'header', + ], + ]); + + expect($schemes)->toBe([ + [ + 'type' => 'http', + 'scheme' => 'basic', + ], + [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ], + [ + 'type' => 'apiKey', + 'name' => 'X-API-KEY', + 'in' => 'header', + ], + ]); +}); diff --git a/tests/Unit/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index c30c158..fdd918c 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; @@ -28,6 +29,23 @@ 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 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(); @@ -112,6 +130,23 @@ class CustomTitleTool extends TestTool protected string $title = 'Custom Title Tool'; } +class CustomMetaTool extends TestTool +{ + protected array $meta = [ + 'key' => 'value', + ]; +} + +class SecuritySchemesTool extends TestTool +{ + public function securitySchemes(SecurityScheme $scheme): array + { + return [ + $scheme::oauth2('read', 'write'), + ]; + } +} + #[IsReadOnly] class ReadOnlyTool extends TestTool {}