diff --git a/composer.json b/composer.json index 26b43d4..eec8ce2 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ ], "require": { "php": "^8.1", + "ext-fileinfo": "*", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/uid": "^6.4 || ^7.0" }, diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 0000000..ca379bf --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Schema/Annotations.php b/src/Schema/Annotations.php new file mode 100644 index 0000000..b07e234 --- /dev/null +++ b/src/Schema/Annotations.php @@ -0,0 +1,87 @@ + + */ +class Annotations implements \JsonSerializable +{ + /** + * @param Role[]|null $audience Describes who the intended customer of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `[Role::User, Role::Assistant]`). + * @param float|null $priority Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + */ + public function __construct( + public readonly ?array $audience = null, + public readonly ?float $priority = null, + ) { + if (null !== $this->priority && ($this->priority < 0 || $this->priority > 1)) { + throw new InvalidArgumentException('Annotation priority must be between 0 and 1.'); + } + if (null !== $this->audience) { + foreach ($this->audience as $role) { + if (!($role instanceof Role)) { + throw new InvalidArgumentException('All audience members must be instances of Role enum.'); + } + } + } + } + + /** + * @param AnnotationsData $data + */ + public static function fromArray(array $data): self + { + $audience = null; + if (isset($data['audience']) && \is_array($data['audience'])) { + $audience = array_map(fn (string $r) => Role::from($r), $data['audience']); + } + + return new self( + $audience, + isset($data['priority']) ? (float) $data['priority'] : null + ); + } + + /** + * @return AnnotationsData + */ + public function jsonSerialize(): array + { + $data = []; + if (null !== $this->audience) { + $data['audience'] = array_map(fn (Role $r) => $r->value, $this->audience); + } + if (null !== $this->priority) { + $data['priority'] = $this->priority; + } + + return $data; + } +} diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php new file mode 100644 index 0000000..0428c2e --- /dev/null +++ b/src/Schema/ClientCapabilities.php @@ -0,0 +1,94 @@ + + */ +class ClientCapabilities implements \JsonSerializable +{ + /** + * @param array $experimental + */ + public function __construct( + public readonly ?bool $roots = false, + public readonly ?bool $rootsListChanged = null, + public readonly ?bool $sampling = null, + public readonly ?array $experimental = null, + ) { + } + + /** + * @param array{ + * roots?: array{ + * listChanged?: bool, + * }, + * sampling?: bool, + * experimental?: array, + * } $data + */ + public static function fromArray(array $data): self + { + $rootsEnabled = isset($data['roots']); + $rootsListChanged = null; + if ($rootsEnabled) { + if (\is_array($data['roots']) && \array_key_exists('listChanged', $data['roots'])) { + $rootsListChanged = (bool) $data['roots']['listChanged']; + } elseif (\is_object($data['roots']) && property_exists($data['roots'], 'listChanged')) { + $rootsListChanged = (bool) $data['roots']->listChanged; + } + } + + $sampling = null; + if (isset($data['sampling'])) { + $sampling = true; + } + + return new self( + $rootsEnabled, + $rootsListChanged, + $sampling, + $data['experimental'] ?? null + ); + } + + /** + * @return array{ + * roots?: object, + * sampling?: object, + * experimental?: object, + * } + */ + public function jsonSerialize(): array + { + $data = []; + if ($this->roots || $this->rootsListChanged) { + $data['roots'] = new \stdClass(); + if ($this->rootsListChanged) { + $data['roots']->listChanged = $this->rootsListChanged; + } + } + + if ($this->sampling) { + $data['sampling'] = new \stdClass(); + } + + if ($this->experimental) { + $data['experimental'] = (object) $this->experimental; + } + + return $data; + } +} diff --git a/src/Schema/Constants.php b/src/Schema/Constants.php new file mode 100644 index 0000000..f3c2866 --- /dev/null +++ b/src/Schema/Constants.php @@ -0,0 +1,29 @@ + + */ +interface Constants +{ + public const LATEST_PROTOCOL_VERSION = '2025-03-26'; + public const JSONRPC_VERSION = '2.0'; + + public const PARSE_ERROR = -32700; + public const INVALID_REQUEST = -32600; + public const METHOD_NOT_FOUND = -32601; + public const INVALID_PARAMS = -32602; + public const INTERNAL_ERROR = -32603; + + public const SERVER_ERROR = -32000; +} diff --git a/src/Schema/Content/AudioContent.php b/src/Schema/Content/AudioContent.php new file mode 100644 index 0000000..a23db24 --- /dev/null +++ b/src/Schema/Content/AudioContent.php @@ -0,0 +1,117 @@ + + */ +class AudioContent extends Content +{ + public function __construct( + public readonly string $data, + public readonly string $mimeType, + public readonly ?Annotations $annotations = null, + ) { + parent::__construct('audio'); + } + + /** + * @param AudioContentData $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['data']) || !isset($data['mimeType'])) { + throw new InvalidArgumentException('Invalid or missing "data" or "mimeType" in AudioContent data.'); + } + + return new self( + $data['data'], + $data['mimeType'], + isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + ); + } + + /** + * Create a new AudioContent from a file path. + * + * @param string $path Path to the audio file + * @param string|null $mimeType Optional MIME type override + * @param ?Annotations $annotations Optional annotations describing the content + * + * @throws InvalidArgumentException If the file doesn't exist + */ + public static function fromFile(string $path, ?string $mimeType = null, ?Annotations $annotations = null): self + { + if (!file_exists($path)) { + throw new InvalidArgumentException(\sprintf('Audio file not found: "%s".', $path)); + } + + $content = file_get_contents($path); + if (false === $content) { + throw new RuntimeException(\sprintf('Could not read audio file: "%s".', $path)); + } + $data = base64_encode($content); + $detectedMime = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream'; + + return new self($data, $detectedMime, $annotations); + } + + /** + * Create a new AudioContent from a string. + * + * @param string $data The audio data + * @param string $mimeType MIME type of the audio + * @param ?Annotations $annotations Optional annotations describing the content + */ + public static function fromString(string $data, string $mimeType, ?Annotations $annotations = null): self + { + return new self(base64_encode($data), $mimeType, $annotations); + } + + /** + * @return array{ + * type: 'audio', + * data: string, + * mimeType: string, + * annotations?: Annotations, + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'type' => 'audio', + 'data' => $this->data, + 'mimeType' => $this->mimeType, + ]; + + if (null !== $this->annotations) { + $result['annotations'] = $this->annotations; + } + + return $result; + } +} diff --git a/src/Schema/Content/BlobResourceContents.php b/src/Schema/Content/BlobResourceContents.php new file mode 100644 index 0000000..c8b07b0 --- /dev/null +++ b/src/Schema/Content/BlobResourceContents.php @@ -0,0 +1,85 @@ + + */ +class BlobResourceContents extends ResourceContents +{ + /** + * @param string $uri the URI of the resource or sub-resource + * @param string|null $mimeType the MIME type of the resource or sub-resource + * @param string $blob a base64-encoded string representing the binary data of the item + */ + public function __construct( + string $uri, + ?string $mimeType, + public readonly string $blob, + ) { + parent::__construct($uri, $mimeType); + } + + /** + * @param BlobResourceContentsData $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Missing or invalid "uri" for BlobResourceContents.'); + } + if (!isset($data['blob']) || !\is_string($data['blob'])) { + throw new InvalidArgumentException('Missing or invalid "blob" for BlobResourceContents.'); + } + + return new self($data['uri'], $data['mimeType'] ?? null, $data['blob']); + } + + /** + * @param resource $stream + */ + public static function fromStream(string $uri, $stream, string $mimeType): self + { + $blob = stream_get_contents($stream); + + return new self($uri, $mimeType, base64_encode($blob)); + } + + public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $explicitMimeType = null): self + { + $mimeType = $explicitMimeType ?? mime_content_type($file->getPathname()); + $blob = file_get_contents($file->getPathname()); + + return new self($uri, $mimeType, base64_encode($blob)); + } + + /** + * @return BlobResourceContentsData + */ + public function jsonSerialize(): array + { + return [ + 'blob' => $this->blob, + ...$this->jsonSerialize(), + ]; + } +} diff --git a/src/Schema/Content/Content.php b/src/Schema/Content/Content.php new file mode 100644 index 0000000..f1ac394 --- /dev/null +++ b/src/Schema/Content/Content.php @@ -0,0 +1,25 @@ + + */ +abstract class Content implements \JsonSerializable +{ + public function __construct( + public readonly string $type, + ) { + } +} diff --git a/src/Schema/Content/EmbeddedResource.php b/src/Schema/Content/EmbeddedResource.php new file mode 100644 index 0000000..eb84985 --- /dev/null +++ b/src/Schema/Content/EmbeddedResource.php @@ -0,0 +1,152 @@ + + */ +class EmbeddedResource extends Content +{ + public function __construct( + public readonly TextResourceContents|BlobResourceContents $resource, + public readonly ?Annotations $annotations = null, + ) { + parent::__construct('resource'); + } + + /** + * @param EmbeddedResourceData $data + */ + public static function fromArray(array $data): self + { + if (($data['type'] ?? null) !== 'resource') { + throw new InvalidArgumentException('Invalid type for EmbeddedResource.'); + } + if (!isset($data['resource']) || !\is_array($data['resource'])) { + throw new InvalidArgumentException('Missing or invalid "resource" field for EmbeddedResource.'); + } + + $resourceData = $data['resource']; + if (isset($resourceData['text'])) { + $resourceInstance = TextResourceContents::fromArray($resourceData); + } elseif (isset($resourceData['blob'])) { + $resourceInstance = BlobResourceContents::fromArray($resourceData); + } else { + throw new InvalidArgumentException('EmbeddedResource "resource" field must contain "text" or "blob".'); + } + + return new self( + $resourceInstance, + isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + ); + } + + public static function fromText(string $uri, string $text, ?string $mimeType = 'text/plain', ?Annotations $annotations = null): self + { + $textContent = new TextResourceContents($uri, $mimeType, $text); + + return new self($textContent, $annotations); + } + + public static function fromBlob(string $uri, string $base64Blob, string $mimeType, ?Annotations $annotations = null): self + { + $blobContent = new BlobResourceContents($uri, $mimeType, $base64Blob); + + return new self($blobContent, $annotations); + } + + public static function fromFile(string $uri, string $path, ?string $explicitMimeType = null, ?Annotations $annotations = null): self + { + if (!file_exists($path) || !is_readable($path)) { + throw new InvalidArgumentException(\sprintf('File not found or not readable: "%s".', $path)); + } + $content = file_get_contents($path); + if (false === $content) { + throw new RuntimeException(\sprintf('Could not read file: "%s".', $path)); + } + + $guessedMimeType = $explicitMimeType ?? mime_content_type($path) ?: 'application/octet-stream'; + + if (self::isTextMimeTypeHeuristic($guessedMimeType) && mb_check_encoding($content, 'UTF-8')) { + $resourceContent = new TextResourceContents($uri, $guessedMimeType, $content); + } else { + $resourceContent = new BlobResourceContents($uri, $guessedMimeType, base64_encode($content)); + } + + return new self($resourceContent, $annotations); + } + + /** + * @param resource $stream + */ + public static function fromStream(string $uri, $stream, string $mimeType, ?Annotations $annotations = null): self + { + $content = stream_get_contents($stream); + if (false === $content) { + throw new RuntimeException('Could not read stream.'); + } + + return new self(new BlobResourceContents($uri, $mimeType, base64_encode($content)), $annotations); + } + + public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $explicitMimeType = null, ?Annotations $annotations = null): self + { + $content = file_get_contents($file->getPathname()); + if (false === $content) { + throw new RuntimeException(\sprintf('Could not read file: "%s".', $file->getPathname())); + } + + return new self(new BlobResourceContents($uri, $explicitMimeType ?? mime_content_type($file->getPathname()), base64_encode($content)), $annotations); + } + + /** + * @return array{ + * type: 'resource', + * resource: TextResourceContents|BlobResourceContents, + * annotations?: Annotations, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'resource' => $this->resource, + ]; + if (null !== $this->annotations) { + $data['annotations'] = $this->annotations; + } + + return $data; + } + + private static function isTextMimeTypeHeuristic(string $mimeType): bool + { + return str_starts_with($mimeType, 'text/') + || \in_array(strtolower($mimeType), ['application/json', 'application/xml', 'application/javascript', 'application/yaml']); + } +} diff --git a/src/Schema/Content/ImageContent.php b/src/Schema/Content/ImageContent.php new file mode 100644 index 0000000..b64d007 --- /dev/null +++ b/src/Schema/Content/ImageContent.php @@ -0,0 +1,93 @@ + + */ +class ImageContent extends Content +{ + /** + * Create a new ImageContent instance. + * + * @param string $data Base64-encoded image data + * @param string $mimeType The MIME type of the image + */ + public function __construct( + public readonly string $data, + public readonly string $mimeType, + ) { + parent::__construct('image'); + } + + /** + * @param ImageContentData $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['data']) || !\is_string($data['data'])) { + throw new InvalidArgumentException('Missing or invalid "data" in ImageContent data.'); + } + if (!isset($data['mimeType']) || !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Missing or invalid "mimeType" in ImageContent data.'); + } + + return new self($data['data'], $data['mimeType']); + } + + /** + * Create a new ImageContent from a file path. + * + * @param string $path Path to the image file + * @param string|null $mimeType Optional MIME type override + * + * @throws InvalidArgumentException If the file doesn't exist + */ + public static function fromFile(string $path, ?string $mimeType = null): self + { + if (!file_exists($path)) { + throw new InvalidArgumentException(\sprintf('Image file not found: "%s".', $path)); + } + + $data = base64_encode(file_get_contents($path)); + $detectedMime = $mimeType ?? mime_content_type($path) ?: 'image/png'; + + return new self($data, $detectedMime); + } + + public static function fromString(string $data, string $mimeType): self + { + return new self(base64_encode($data), $mimeType); + } + + /** + * Convert the content to an array. + * + * @return ImageContentData + */ + public function jsonSerialize(): array + { + return [ + 'type' => $this->type, + 'data' => $this->data, + 'mimeType' => $this->mimeType, + ]; + } +} diff --git a/src/Schema/Content/PromptMessage.php b/src/Schema/Content/PromptMessage.php new file mode 100644 index 0000000..075d113 --- /dev/null +++ b/src/Schema/Content/PromptMessage.php @@ -0,0 +1,88 @@ + + */ +class PromptMessage extends Content +{ + /** + * Create a new PromptMessage instance. + * + * @param Role $role The role of the message + * @param TextContent|ImageContent|AudioContent|EmbeddedResource $content The content of the message + */ + public function __construct( + public readonly Role $role, + public readonly TextContent|ImageContent|AudioContent|EmbeddedResource $content, + ) { + parent::__construct('prompt'); + } + + /** + * @param PromptMessageData $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['role']) || !\is_string($data['role'])) { + throw new InvalidArgumentException('Missing or invalid "role" in PromptMessage data.'); + } + if (!isset($data['content']) || !\is_array($data['content'])) { + throw new InvalidArgumentException('Missing or invalid "content" in PromptMessage data.'); + } + + $contentData = $data['content']; + $contentType = $contentData['type'] ?? null; + + $content = match ($contentType) { + 'text' => TextContent::fromArray($contentData), + 'image' => ImageContent::fromArray($contentData), + 'audio' => AudioContent::fromArray($contentData), + 'resource' => EmbeddedResource::fromArray($contentData), + default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for PromptMessage.', $contentType)), + }; + + return new self(Role::from($data['role']), $content); + } + + /** + * Convert the message to an array. + * + * @return array{ + * role: string, + * content: TextContent|ImageContent|AudioContent|EmbeddedResource + * } + */ + public function jsonSerialize(): array + { + return [ + 'role' => $this->role->value, + 'content' => $this->content, + ]; + } +} diff --git a/src/Schema/Content/ResourceContents.php b/src/Schema/Content/ResourceContents.php new file mode 100644 index 0000000..d4c7733 --- /dev/null +++ b/src/Schema/Content/ResourceContents.php @@ -0,0 +1,48 @@ + + */ +abstract class ResourceContents implements \JsonSerializable +{ + /** + * @param string $uri the URI of the resource or sub-resource + * @param string|null $mimeType the MIME type of the resource or sub-resource + */ + public function __construct( + public readonly string $uri, + public readonly ?string $mimeType = null, + ) { + } + + /** + * @return ResourceContentsData + */ + public function jsonSerialize(): array + { + $data = ['uri' => $this->uri]; + if (null !== $this->mimeType) { + $data['mimeType'] = $this->mimeType; + } + + return $data; + } +} diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php new file mode 100644 index 0000000..b34835d --- /dev/null +++ b/src/Schema/Content/SamplingMessage.php @@ -0,0 +1,72 @@ + + */ +class SamplingMessage extends Content +{ + public function __construct( + public readonly Role $role, + public readonly TextContent|ImageContent|AudioContent $content, + ) { + parent::__construct('sampling'); + } + + /** + * @param SamplingMessageData $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['role']) || !\is_string($data['role'])) { + throw new InvalidArgumentException('Missing or invalid "role" in SamplingMessage data.'); + } + if (!isset($data['content']) || !\is_array($data['content'])) { + throw new InvalidArgumentException('Missing or invalid "content" in SamplingMessage data.'); + } + + $role = Role::from($data['role']); + $contentData = $data['content']; + $contentType = $contentData['type'] ?? null; + + $contentInstance = match ($contentType) { + 'text' => TextContent::fromArray($contentData), + 'image' => ImageContent::fromArray($contentData), + 'audio' => AudioContent::fromArray($contentData), + default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), + }; + + return new self($role, $contentInstance); + } + + /** + * @return SamplingMessageData + */ + public function jsonSerialize(): array + { + return [ + 'role' => $this->role->value, + 'content' => $this->content, + ]; + } +} diff --git a/src/Schema/Content/TextContent.php b/src/Schema/Content/TextContent.php new file mode 100644 index 0000000..14a6402 --- /dev/null +++ b/src/Schema/Content/TextContent.php @@ -0,0 +1,96 @@ + + */ +class TextContent extends Content +{ + /** + * Create a new TextContent instance from any value. + * + * @param mixed $text The value to convert to text + * @param ?Annotations $annotations Optional annotations describing the content + */ + public function __construct( + public mixed $text, + public readonly ?Annotations $annotations = null, + ) { + $this->text = (\is_array($text) || \is_object($text)) + ? json_encode($text, \JSON_PRETTY_PRINT) : (string) $text; + + parent::__construct('text'); + } + + /** + * @param TextContentData $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['text']) || !\is_string($data['text'])) { + throw new InvalidArgumentException('Missing or invalid "text" in TextContent data.'); + } + + return new self( + $data['text'], + isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + ); + } + + /** + * Create a new TextContent with markdown formatted code. + * + * @param string $code The code to format + * @param string $language The language for syntax highlighting + */ + public static function code(string $code, string $language = '', ?Annotations $annotations = null): self + { + return new self("```{$language}\n{$code}\n```", $annotations); + } + + /** + * Convert the content to an array. + * + * @return array{ + * type: 'text', + * text: string, + * annotations?: Annotations, + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'type' => 'text', + 'text' => $this->text, + ]; + + if (null !== $this->annotations) { + $result['annotations'] = $this->annotations; + } + + return $result; + } +} diff --git a/src/Schema/Content/TextResourceContents.php b/src/Schema/Content/TextResourceContents.php new file mode 100644 index 0000000..49fc4eb --- /dev/null +++ b/src/Schema/Content/TextResourceContents.php @@ -0,0 +1,67 @@ + + */ +class TextResourceContents extends ResourceContents +{ + /** + * @param string $uri the URI of the resource or sub-resource + * @param string|null $mimeType the MIME type of the resource or sub-resource + * @param string $text The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + public function __construct( + string $uri, + ?string $mimeType, + public readonly string $text, + ) { + parent::__construct($uri, $mimeType); + } + + /** + * @param TextResourceContentsData $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Missing or invalid "uri" for TextResourceContents.'); + } + if (!isset($data['text']) || !\is_string($data['text'])) { + throw new InvalidArgumentException('Missing or invalid "text" for TextResourceContents.'); + } + + return new self($data['uri'], $data['mimeType'] ?? null, $data['text']); + } + + /** + * @return TextResourceContentsData + */ + public function jsonSerialize(): array + { + return [ + 'text' => $this->text, + ...$this->jsonSerialize(), + ]; + } +} diff --git a/src/Schema/Enum/LoggingLevel.php b/src/Schema/Enum/LoggingLevel.php new file mode 100644 index 0000000..e9aecef --- /dev/null +++ b/src/Schema/Enum/LoggingLevel.php @@ -0,0 +1,32 @@ + + */ +enum LoggingLevel: string +{ + case Debug = 'debug'; + case Info = 'info'; + case Notice = 'notice'; + case Warning = 'warning'; + case Error = 'error'; + case Critical = 'critical'; + case Alert = 'alert'; + case Emergency = 'emergency'; +} diff --git a/src/Schema/Enum/Role.php b/src/Schema/Enum/Role.php new file mode 100644 index 0000000..0b1d97a --- /dev/null +++ b/src/Schema/Enum/Role.php @@ -0,0 +1,23 @@ + + */ +enum Role: string +{ + case User = 'user'; + case Assistant = 'assistant'; +} diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php new file mode 100644 index 0000000..7cde41b --- /dev/null +++ b/src/Schema/Implementation.php @@ -0,0 +1,60 @@ + + */ +class Implementation implements \JsonSerializable +{ + public function __construct( + public readonly string $name = 'app', + public readonly string $version = 'dev', + ) { + } + + /** + * @param array{ + * name: string, + * version: string, + * } $data + */ + public static function fromArray(array $data): self + { + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in Implementation data.'); + } + if (empty($data['version']) || !\is_string($data['version'])) { + throw new InvalidArgumentException('Invalid or missing "version" in Implementation data.'); + } + + return new self($data['name'], $data['version']); + } + + /** + * @return array{ + * name: string, + * version: string, + * } + */ + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'version' => $this->version, + ]; + } +} diff --git a/src/Schema/JsonRpc/BatchRequest.php b/src/Schema/JsonRpc/BatchRequest.php new file mode 100644 index 0000000..936f322 --- /dev/null +++ b/src/Schema/JsonRpc/BatchRequest.php @@ -0,0 +1,164 @@ + + */ +class BatchRequest implements MessageInterface, \Countable +{ + /** + * Create a new JSON-RPC 2.0 batch of requests/notifications. + * + * @param array $items + */ + public function __construct(public readonly array $items) + { + foreach ($items as $item) { + if (!($item instanceof Request || $item instanceof Notification)) { + throw new InvalidArgumentException('All items in BatchRequest must be instances of Request or Notification.'); + } + } + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + if (empty($data)) { + throw new InvalidArgumentException('BatchRequest data array must not be empty.'); + } + + $items = []; + foreach ($data as $itemData) { + if (!\is_array($itemData)) { + throw new InvalidArgumentException('BatchRequest item data must be an array.'); + } + if (isset($itemData['id'])) { + $items[] = Request::fromArray($itemData); + } elseif (isset($itemData['method'])) { + $items[] = Notification::fromArray($itemData); + } else { + throw new InvalidArgumentException('Invalid item in BatchRequest data: missing "method" or "id".'); + } + } + + return new self($items); + } + + public function getId(): string|int + { + foreach ($this->items as $item) { + if ($item instanceof Request) { + return $item->getId(); + } + } + + return ''; + } + + /** + * Check if this batch has any requests. + */ + public function hasRequests(): bool + { + $hasRequests = false; + foreach ($this->items as $item) { + if ($item instanceof Request) { + $hasRequests = true; + break; + } + } + + return $hasRequests; + } + + /** + * Check if this batch has any notifications. + */ + public function hasNotifications(): bool + { + $hasNotifications = false; + foreach ($this->items as $item) { + if ($item instanceof Notification) { + $hasNotifications = true; + break; + } + } + + return $hasNotifications; + } + + /** + * Get all requests in this batch. + * + * @return array + */ + public function getRequests(): array + { + return array_filter($this->items, fn ($item) => $item instanceof Request); + } + + /** + * Get all notifications in this batch. + * + * @return array + */ + public function getNotifications(): array + { + return array_filter($this->items, fn ($item) => $item instanceof Notification); + } + + /** + * Get all elements in this batch. + * + * @return array + */ + public function getAll(): array + { + return $this->items; + } + + /** + * Count the total number of elements in this batch. + */ + public function count(): int + { + return \count($this->items); + } + + public function nRequests(): int + { + return \count($this->getRequests()); + } + + public function nNotifications(): int + { + return \count($this->getNotifications()); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->items; + } +} diff --git a/src/Schema/JsonRpc/BatchResponse.php b/src/Schema/JsonRpc/BatchResponse.php new file mode 100644 index 0000000..3c58baf --- /dev/null +++ b/src/Schema/JsonRpc/BatchResponse.php @@ -0,0 +1,138 @@ + + */ +class BatchResponse implements MessageInterface, \Countable +{ + /** + * @param array $items the individual responses/errors in this batch + */ + public function __construct( + public array $items, + ) { + foreach ($items as $item) { + if (!($item instanceof Response || $item instanceof Error)) { + throw new InvalidArgumentException('All items in BatchResponse must be instances of Response or Error.'); + } + } + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + if (empty($data)) { + throw new InvalidArgumentException('BatchResponse data array must not be empty.'); + } + + $items = []; + foreach ($data as $itemData) { + if (!\is_array($itemData)) { + throw new InvalidArgumentException('BatchResponse item data must be an array.'); + } + if (isset($itemData['id'])) { + $items[] = Response::fromArray($itemData); + } elseif (isset($itemData['error'])) { + $items[] = Error::fromArray($itemData); + } else { + throw new InvalidArgumentException('Invalid item in BatchResponse data: missing "id" or "error".'); + } + } + + return new self($items); + } + + public function getId(): string|int + { + foreach ($this->items as $item) { + if ($item instanceof Response) { + return $item->getId(); + } + } + + throw new InvalidArgumentException('BatchResponse does not contain any Response items with an "id".'); + } + + public function hasResponses(): bool + { + $hasResponses = false; + foreach ($this->items as $item) { + if ($item instanceof Response) { + $hasResponses = true; + break; + } + } + + return $hasResponses; + } + + public function hasErrors(): bool + { + $hasErrors = false; + foreach ($this->items as $item) { + if ($item instanceof Error) { + $hasErrors = true; + break; + } + } + + return $hasErrors; + } + + /** + * @return Response[] + */ + public function getResponses(): array + { + return array_filter($this->items, fn ($item) => $item instanceof Response); + } + + /** + * @return Error[] + */ + public function getErrors(): array + { + return array_filter($this->items, fn ($item) => $item instanceof Error); + } + + /** + * @return Error[]|Response[] + */ + public function getAll(): array + { + return $this->items; + } + + public function count(): int + { + return \count($this->items); + } + + /** + * @return Error[]|Response[] + */ + public function jsonSerialize(): array + { + return $this->items; + } +} diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php new file mode 100644 index 0000000..eaf6245 --- /dev/null +++ b/src/Schema/JsonRpc/Error.php @@ -0,0 +1,129 @@ + + */ +class Error implements MessageInterface +{ + /** + * @param int $code the error type that occurred + * @param string $message a short description of the error + * @param mixed|null $data additional information about the error + */ + public function __construct( + public readonly string|int $id, + public readonly int $code, + public readonly string $message, + public readonly mixed $data = null, + ) { + } + + /** + * @param ErrorData $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['jsonrpc']) || Constants::JSONRPC_VERSION !== $data['jsonrpc']) { + throw new InvalidArgumentException('Invalid or missing "jsonrpc" in Error data.'); + } + if (!isset($data['id']) || !\is_string($data['id'])) { + throw new InvalidArgumentException('Invalid or missing "id" in Error data.'); + } + if (!isset($data['code']) || !\is_int($data['code'])) { + throw new InvalidArgumentException('Invalid or missing "code" in Error data.'); + } + if (!isset($data['message']) || !\is_string($data['message'])) { + throw new InvalidArgumentException('Invalid or missing "message" in Error data.'); + } + + return new self($data['id'], $data['code'], $data['message'], $data['data'] ?? null); + } + + public static function forParseError(string $message, string|int $id = ''): self + { + return new self($id, Constants::PARSE_ERROR, $message); + } + + public static function forInvalidRequest(string $message, string|int $id = ''): self + { + return new self($id, Constants::INVALID_REQUEST, $message); + } + + public static function forMethodNotFound(string $message, string|int $id = ''): self + { + return new self($id, Constants::METHOD_NOT_FOUND, $message); + } + + public static function forInvalidParams(string $message, string|int $id = ''): self + { + return new self($id, Constants::INVALID_PARAMS, $message); + } + + public static function forInternalError(string $message, string|int $id = ''): self + { + return new self($id, Constants::INTERNAL_ERROR, $message); + } + + public static function forServerError(string $message, string|int $id = ''): self + { + return new self($id, Constants::SERVER_ERROR, $message); + } + + public function getId(): string|int + { + return $this->id; + } + + /** + * @return array{ + * jsonrpc: string, + * id: string|int, + * error: array{ + * code: int, + * message: string, + * }, + * data?: mixed, + * } + */ + public function jsonSerialize(): array + { + $error = [ + 'code' => $this->code, + 'message' => $this->message, + ]; + + if (null !== $this->data) { + $error['data'] = $this->data; + } + + return [ + 'jsonrpc' => Constants::JSONRPC_VERSION, + 'id' => $this->id, + 'error' => $error, + ]; + } +} diff --git a/src/Schema/JsonRpc/MessageInterface.php b/src/Schema/JsonRpc/MessageInterface.php new file mode 100644 index 0000000..0f73a6b --- /dev/null +++ b/src/Schema/JsonRpc/MessageInterface.php @@ -0,0 +1,21 @@ + + */ +interface MessageInterface extends \JsonSerializable +{ +} diff --git a/src/Schema/JsonRpc/Notification.php b/src/Schema/JsonRpc/Notification.php new file mode 100644 index 0000000..07cf98d --- /dev/null +++ b/src/Schema/JsonRpc/Notification.php @@ -0,0 +1,80 @@ +|null + * } + * + * @author Kyrian Obikwelu + */ +class Notification implements MessageInterface +{ + /** + * @param string $method the name of the method to be invoked + * @param ?array $params parameters for the method + */ + public function __construct( + public readonly string $method, + public readonly ?array $params = null, + ) { + } + + /** + * @param NotificationData $data + */ + public static function fromArray(array $data): self + { + if (isset($data['id'])) { + throw new InvalidArgumentException('Notification MUST NOT contain an "id" field.'); + } + if (!isset($data['method']) || !\is_string($data['method'])) { + throw new InvalidArgumentException('Invalid or missing "method" for Notification.'); + } + $params = $data['params'] ?? null; + if (null !== $params && !\is_array($params)) { + throw new InvalidArgumentException('"params" for Notification must be an array/object or null.'); + } + + return new self($data['method'], $params); + } + + /** + * @return null + */ + public function getId() + { + return null; + } + + /** + * @return NotificationData + */ + public function jsonSerialize(): array + { + $array = [ + 'jsonrpc' => Constants::JSONRPC_VERSION, + 'method' => $this->method, + ]; + if (null !== $this->params) { + $array['params'] = $this->params; + } + + return $array; + } +} diff --git a/src/Schema/JsonRpc/Parser.php b/src/Schema/JsonRpc/Parser.php new file mode 100644 index 0000000..5a39e9e --- /dev/null +++ b/src/Schema/JsonRpc/Parser.php @@ -0,0 +1,80 @@ + + */ +class Parser +{ + /** + * Parses a raw JSON string into a JSON-RPC Message object (Request, Notification, Response, Error, or Batch variants). + * + * This method determines if the incoming message is a request-like message (Request, Notification, BatchRequest) + * or a response-like message (Response, Error, BatchResponse) based on the presence of 'method' vs 'result'/'error'. + * + * @param string $json the raw JSON string to parse + * + * @return MessageInterface a specific instance of Request, Notification, Response, Error, BatchRequest, or BatchResponse + * + * @throws \JsonException if the string is not valid JSON + * @throws InvalidArgumentException if the JSON structure does not conform to a recognizable JSON-RPC message type + */ + public static function parse(string $json): MessageInterface + { + $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + + if (!\is_array($data)) { + throw new InvalidArgumentException('Invalid JSON-RPC message: Root must be an object or array.'); + } + + if (array_is_list($data) && !empty($data)) { + $firstItem = $data[0]; + if (!\is_array($firstItem)) { + throw new InvalidArgumentException('Invalid JSON-RPC batch: Items must be objects.'); + } + + if (isset($firstItem['method'])) { + return BatchRequest::fromArray($data); + } elseif (isset($firstItem['id']) && (isset($firstItem['result']) || isset($firstItem['error']))) { + return BatchResponse::fromArray($data); + } else { + throw new InvalidArgumentException('Invalid JSON-RPC batch: Items are not recognizable requests or responses.'); + } + } + + if (!isset($data['jsonrpc']) || Constants::JSONRPC_VERSION !== $data['jsonrpc']) { + throw new InvalidArgumentException('Invalid or missing "jsonrpc" version. Must be "'.Constants::JSONRPC_VERSION.'".'); + } + + if (isset($data['method'])) { + if (isset($data['id'])) { + return Request::fromArray($data); + } else { + return Notification::fromArray($data); + } + } elseif (isset($data['id'])) { + if (\array_key_exists('result', $data)) { + return Response::fromArray($data); + } elseif (isset($data['error'])) { + return Error::fromArray($data); + } else { + throw new InvalidArgumentException('Invalid JSON-RPC response/error: Missing "result" or "error" field for message with "id".'); + } + } + + throw new InvalidArgumentException('Unrecognized JSON-RPC message structure.'); + } +} diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php new file mode 100644 index 0000000..24e2cc9 --- /dev/null +++ b/src/Schema/JsonRpc/Request.php @@ -0,0 +1,87 @@ +, + * } + * + * @author Kyrian Obikwelu + */ +class Request implements MessageInterface +{ + /** + * @param string|int $id a unique identifier for the request + * @param string $method the name of the method to be invoked + * @param array|null $params parameters for the method + */ + public function __construct( + public readonly string|int $id, + public readonly string $method, + public readonly ?array $params = null, + ) { + } + + public function getId(): string|int + { + return $this->id; + } + + /** + * @param RequestData $data + */ + public static function fromArray(array $data): self + { + if (($data['jsonrpc'] ?? null) !== Constants::JSONRPC_VERSION) { + throw new InvalidArgumentException('Invalid or missing "jsonrpc" version for Request.'); + } + if (!isset($data['id']) || !\is_string($data['id']) && !\is_int($data['id'])) { + throw new InvalidArgumentException('Invalid or missing "id" for Request.'); + } + if (!isset($data['method']) || !\is_string($data['method'])) { + throw new InvalidArgumentException('Invalid or missing "method" for Request.'); + } + $params = $data['params'] ?? null; + if ($params instanceof \stdClass) { + $params = (array) $params; + } + if (null !== $params && !\is_array($params)) { + throw new InvalidArgumentException('"params" for Request must be an array/object or null.'); + } + + return new self($data['id'], $data['method'], $params); + } + + /** + * @return RequestData + */ + public function jsonSerialize(): array + { + $array = [ + 'jsonrpc' => Constants::JSONRPC_VERSION, + 'id' => $this->id, + 'method' => $this->method, + ]; + if (null !== $this->params) { + $array['params'] = $this->params; + } + + return $array; + } +} diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php new file mode 100644 index 0000000..cfd82f5 --- /dev/null +++ b/src/Schema/JsonRpc/Response.php @@ -0,0 +1,79 @@ + + * + * @phpstan-type ResponseData array{ + * jsonrpc: string, + * id: string|int, + * result: array, + * } + */ +class Response implements MessageInterface +{ + /** + * @param string|int $id this MUST be the same as the value of the id member in the Request Object + * @param ResultInterface|array $result the value of this member is determined by the method invoked on the Server + */ + public function __construct( + public readonly string|int $id, + public readonly ResultInterface|array $result, + ) { + } + + public function getId(): string|int + { + return $this->id; + } + + /** + * @param ResponseData $data + */ + public static function fromArray(array $data): self + { + if (($data['jsonrpc'] ?? null) !== Constants::JSONRPC_VERSION) { + throw new InvalidArgumentException('Invalid or missing "jsonrpc" version for Response.'); + } + if (!isset($data['id'])) { + throw new InvalidArgumentException('Missing "id" for Response.'); + } + if (!\is_string($data['id']) && !\is_int($data['id'])) { + throw new InvalidArgumentException('Invalid "id" type for Response.'); + } + if (!isset($data['result'])) { + throw new InvalidArgumentException('Response must contain "result" field.'); + } + + return new self($data['id'], $data['result']); + } + + /** + * @return array{ + * jsonrpc: string, + * id: string|int, + * result: ResultInterface, + * } + */ + public function jsonSerialize(): array + { + return [ + 'jsonrpc' => Constants::JSONRPC_VERSION, + 'id' => $this->id, + 'result' => $this->result, + ]; + } +} diff --git a/src/Schema/JsonRpc/ResultInterface.php b/src/Schema/JsonRpc/ResultInterface.php new file mode 100644 index 0000000..180dfba --- /dev/null +++ b/src/Schema/JsonRpc/ResultInterface.php @@ -0,0 +1,21 @@ + + */ +interface ResultInterface extends \JsonSerializable +{ +} diff --git a/src/Schema/ModelHint.php b/src/Schema/ModelHint.php new file mode 100644 index 0000000..0ad04cc --- /dev/null +++ b/src/Schema/ModelHint.php @@ -0,0 +1,50 @@ + + */ +class ModelHint implements \JsonSerializable +{ + /** + * @param string|null $name A hint for a model name. + * + * The client SHOULD treat this as a substring of a model name; for example: + * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + * - `claude` should match any Claude model + * + * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + */ + public function __construct( + public readonly ?string $name = null, + ) { + } + + /** + * @return array{name: string}|array{} + */ + public function jsonSerialize(): array + { + if (null === $this->name) { + return []; + } + + return ['name' => $this->name]; + } +} diff --git a/src/Schema/ModelPreferences.php b/src/Schema/ModelPreferences.php new file mode 100644 index 0000000..3e7c9b5 --- /dev/null +++ b/src/Schema/ModelPreferences.php @@ -0,0 +1,78 @@ + + */ +class ModelPreferences implements \JsonSerializable +{ + /** + * @param ModelHint[]|null $hints Optional hints about the model to use. + * + * If multiple hints are specified, the client MUST evaluate them in order (such that the first match is taken). + * + * The client SHOULD prioritize these hints over the numeric priorities, but MAY still use the priorities to select from ambiguous matches. + * @param float|null $costPriority How much to prioritize cost when selecting a model. A value of 0 means cost is not important, while + * a value of 1 means cost is the most important factor. Minimum value is 0, maximum value is 1. + * @param float|null $speedPriority How much to prioritize sampling speed (latency) when selecting a model. A value of 0 means + * speed is not important, while a value of 1 means speed is the most important factor. Minimum value is 0, maximum value is 1. + * @param float|null $intelligencePriority How much to prioritize intelligence and capabilities when selecting a model. A value of 0 + * means intelligence is not important, while a value of 1 means intelligence is the most important factor. + */ + public function __construct( + public readonly ?array $hints = null, + public readonly ?float $costPriority = null, + public readonly ?float $speedPriority = null, + public readonly ?float $intelligencePriority = null, + ) { + } + + /** + * @return array{ + * hints?: ModelHint[], + * costPriority?: float, + * speedPriority?: float, + * intelligencePriority?: float, + * } + */ + public function jsonSerialize(): array + { + $result = []; + if (null !== $this->hints) { + $result['hints'] = $this->hints; + } + if (null !== $this->costPriority) { + $result['costPriority'] = $this->costPriority; + } + if (null !== $this->speedPriority) { + $result['speedPriority'] = $this->speedPriority; + } + if (null !== $this->intelligencePriority) { + $result['intelligencePriority'] = $this->intelligencePriority; + } + + return $result; + } +} diff --git a/src/Schema/Notification/CancelledNotification.php b/src/Schema/Notification/CancelledNotification.php new file mode 100644 index 0000000..7806676 --- /dev/null +++ b/src/Schema/Notification/CancelledNotification.php @@ -0,0 +1,67 @@ + + */ +class CancelledNotification extends Notification +{ + /** + * @param string $requestId The ID of the request that is being cancelled. This MUST correspond to the ID of a request previously issued in the same direction. + * @param ?string $reason An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + * @param ?array $_meta additional metadata about the notification + */ + public function __construct( + public readonly string $requestId, + public readonly ?string $reason = null, + public readonly ?array $_meta = null, + ) { + $params = [ + 'requestId' => $this->requestId, + ]; + if (null !== $this->reason) { + $params['reason'] = $this->reason; + } + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/cancelled', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/cancelled' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/cancelled notification'); + } + + $params = $notification->params; + + if (!isset($params['requestId']) || !\is_string($params['requestId'])) { + throw new InvalidArgumentException('Missing or invalid requestId parameter for notifications/cancelled notification'); + } + + return new self($params['requestId'], $params['reason'] ?? null, $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Notification/InitializedNotification.php b/src/Schema/Notification/InitializedNotification.php new file mode 100644 index 0000000..135fd74 --- /dev/null +++ b/src/Schema/Notification/InitializedNotification.php @@ -0,0 +1,47 @@ + + */ +class InitializedNotification extends Notification +{ + /** + * @param array|null $_meta + */ + public function __construct( + public readonly ?array $_meta = null, + ) { + $params = []; + + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/initialized', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/initialized' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/initialized notification'); + } + + return new self($notification->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Notification/LoggingMessageNotification.php b/src/Schema/Notification/LoggingMessageNotification.php new file mode 100644 index 0000000..3579312 --- /dev/null +++ b/src/Schema/Notification/LoggingMessageNotification.php @@ -0,0 +1,71 @@ + + */ +class LoggingMessageNotification extends Notification +{ + /** + * @param LoggingLevel $level the severity of this log message + * @param mixed $data The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + * @param string $logger an optional name of the logger issuing this message + * @param ?array $_meta optional metadata to include in the notification + */ + public function __construct( + public readonly LoggingLevel $level, + public readonly mixed $data, + public readonly ?string $logger = null, + public readonly ?array $_meta = null, + ) { + $params = [ + 'level' => $level->value, + 'data' => \is_string($data) ? $data : json_encode($data), + ]; + + if (null !== $logger) { + $params['logger'] = $logger; + } + + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/message', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/message' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/message notification'); + } + + $params = $notification->params; + + if (!isset($params['level']) || !\is_string($params['level'])) { + throw new InvalidArgumentException('Missing or invalid level parameter for notifications/message notification'); + } + + if (!isset($params['data'])) { + throw new InvalidArgumentException('Missing data parameter for notifications/message notification'); + } + + $level = LoggingLevel::from($params['level']); + + return new self($level, $params['data'], $params['logger'] ?? null, $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Notification/ProgressNotification.php b/src/Schema/Notification/ProgressNotification.php new file mode 100644 index 0000000..5909138 --- /dev/null +++ b/src/Schema/Notification/ProgressNotification.php @@ -0,0 +1,66 @@ + + */ +class ProgressNotification extends Notification +{ + /** + * @param string|int $progressToken the progress token which was given in the initial request, used to + * associate this notification with the request that is proceeding + * @param float $progress The progress thus far. This should increase every time progress is + * made, even if the total is unknown. + * @param ?float $total total number of items to process (or total progress required), if known + * @param ?string $message an optional message describing the current progress + * @param ?array $_meta optional metadata to include in the notification + */ + public function __construct( + public readonly string|int $progressToken, + public readonly float $progress, + public readonly ?float $total = null, + public readonly ?string $message = null, + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/progress', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/progress' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/progress notification'); + } + + $params = $notification->params; + + if (!isset($params['progressToken']) || !\is_string($params['progressToken'])) { + throw new InvalidArgumentException('Missing or invalid progressToken parameter for notifications/progress notification'); + } + + if (!isset($params['progress']) || !\is_float($params['progress'])) { + throw new InvalidArgumentException('Missing or invalid progress parameter for notifications/progress notification'); + } + + return new self($params['progressToken'], $params['progress'], $params['total'] ?? null, $params['message'] ?? null, $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Notification/PromptListChangedNotification.php b/src/Schema/Notification/PromptListChangedNotification.php new file mode 100644 index 0000000..e2ab2d3 --- /dev/null +++ b/src/Schema/Notification/PromptListChangedNotification.php @@ -0,0 +1,46 @@ + + */ +class PromptListChangedNotification extends Notification +{ + /** + * @param array|null $_meta + */ + public function __construct( + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/prompts/list_changed', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/prompts/list_changed' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/prompts/list_changed notification'); + } + + return new self($notification->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Notification/ResourceListChangedNotification.php b/src/Schema/Notification/ResourceListChangedNotification.php new file mode 100644 index 0000000..6a03c5d --- /dev/null +++ b/src/Schema/Notification/ResourceListChangedNotification.php @@ -0,0 +1,46 @@ + + */ +class ResourceListChangedNotification extends Notification +{ + /** + * @param array|null $_meta + */ + public function __construct( + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/resources/list_changed', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/resources/list_changed' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/resources/list_changed notification'); + } + + return new self($notification->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Notification/ResourceUpdatedNotification.php b/src/Schema/Notification/ResourceUpdatedNotification.php new file mode 100644 index 0000000..a5d071e --- /dev/null +++ b/src/Schema/Notification/ResourceUpdatedNotification.php @@ -0,0 +1,51 @@ + + */ +class ResourceUpdatedNotification extends Notification +{ + /** + * @param array|null $_meta + */ + public function __construct( + public readonly string $uri, + public readonly ?array $_meta = null, + ) { + $params = ['uri' => $uri]; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/resources/updated', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/resources/updated' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/resources/updated notification'); + } + + $params = $notification->params; + + if (!isset($params['uri']) || !\is_string($params['uri'])) { + throw new InvalidArgumentException('Missing or invalid uri parameter for notifications/resources/updated notification'); + } + + return new self($params['uri'], $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Notification/RootsListChangedNotification.php b/src/Schema/Notification/RootsListChangedNotification.php new file mode 100644 index 0000000..8c3e8b6 --- /dev/null +++ b/src/Schema/Notification/RootsListChangedNotification.php @@ -0,0 +1,48 @@ + + */ +class RootsListChangedNotification extends Notification +{ + /** + * @param array|null $_meta + */ + public function __construct( + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/roots/list_changed', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/roots/list_changed' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/roots/list_changed notification'); + } + + return new self($notification->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Notification/ToolListChangedNotification.php b/src/Schema/Notification/ToolListChangedNotification.php new file mode 100644 index 0000000..64d42a9 --- /dev/null +++ b/src/Schema/Notification/ToolListChangedNotification.php @@ -0,0 +1,46 @@ + + */ +class ToolListChangedNotification extends Notification +{ + /** + * @param array|null $_meta + */ + public function __construct( + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct('notifications/tools/list_changed', $params); + } + + public static function fromNotification(Notification $notification): self + { + if ('notifications/tools/list_changed' !== $notification->method) { + throw new InvalidArgumentException('Notification is not a notifications/tools/list_changed notification'); + } + + return new self($notification->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php new file mode 100644 index 0000000..96cffcd --- /dev/null +++ b/src/Schema/Prompt.php @@ -0,0 +1,89 @@ + + */ +class Prompt implements \JsonSerializable +{ + /** + * @param string $name the name of the prompt or prompt template + * @param string|null $description an optional description of what this prompt provides + * @param PromptArgument[]|null $arguments A list of arguments for templating. Null if not a template. + */ + public function __construct( + public readonly string $name, + public readonly ?string $description = null, + public readonly ?array $arguments = null, + ) { + if (null !== $this->arguments) { + foreach ($this->arguments as $arg) { + if (!($arg instanceof PromptArgument)) { + throw new InvalidArgumentException('All items in Prompt "arguments" must be PromptArgument instances.'); + } + } + } + } + + /** + * @param PromptData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in Prompt data.'); + } + $arguments = null; + if (isset($data['arguments']) && \is_array($data['arguments'])) { + $arguments = array_map(fn (array $argData) => PromptArgument::fromArray($argData), $data['arguments']); + } + + return new self( + name: $data['name'], + description: $data['description'] ?? null, + arguments: $arguments + ); + } + + /** + * @return array{ + * name: string, + * description?: string, + * arguments?: array + * } + */ + public function jsonSerialize(): array + { + $data = ['name' => $this->name]; + if (null !== $this->description) { + $data['description'] = $this->description; + } + if (null !== $this->arguments) { + $data['arguments'] = $this->arguments; + } + + return $data; + } +} diff --git a/src/Schema/PromptArgument.php b/src/Schema/PromptArgument.php new file mode 100644 index 0000000..3ef45ca --- /dev/null +++ b/src/Schema/PromptArgument.php @@ -0,0 +1,72 @@ + + */ +class PromptArgument implements \JsonSerializable +{ + /** + * @param string $name the name of the argument + * @param string|null $description a human-readable description of the argument + * @param bool|null $required Whether this argument must be provided. Defaults to false per MCP spec if omitted. + */ + public function __construct( + public readonly string $name, + public readonly ?string $description = null, + public readonly ?bool $required = null, + ) { + } + + /** + * @param PromptArgumentData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in PromptArgument data.'); + } + + return new self( + name: $data['name'], + description: $data['description'] ?? null, + required: $data['required'] ?? null // Keep null if not present, MCP implies default false + ); + } + + /** + * @return PromptArgumentData + */ + public function jsonSerialize(): array + { + $data = ['name' => $this->name]; + if (null !== $this->description) { + $data['description'] = $this->description; + } + if (null !== $this->required) { + $data['required'] = $this->required; + } + + return $data; + } +} diff --git a/src/Schema/PromptReference.php b/src/Schema/PromptReference.php new file mode 100644 index 0000000..10ec755 --- /dev/null +++ b/src/Schema/PromptReference.php @@ -0,0 +1,44 @@ + + */ +class PromptReference implements \JsonSerializable +{ + public string $type = 'ref/prompt'; + + /** + * @param string $name The name of the prompt or prompt template + */ + public function __construct( + public readonly string $name, + ) { + } + + /** + * @return array{ + * type: string, + * name: string, + * } + */ + public function jsonSerialize(): array + { + return [ + 'type' => $this->type, + 'name' => $this->name, + ]; + } +} diff --git a/src/Schema/Request/CallToolRequest.php b/src/Schema/Request/CallToolRequest.php new file mode 100644 index 0000000..2e3a972 --- /dev/null +++ b/src/Schema/Request/CallToolRequest.php @@ -0,0 +1,76 @@ + + */ +class CallToolRequest extends Request +{ + /** + * @param string $name the name of the tool to invoke + * @param array $arguments the arguments to pass to the tool + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly string $name, + public readonly array $arguments, + public readonly ?array $_meta = null, + ) { + $params = [ + 'name' => $name, + 'arguments' => (object) $arguments, + ]; + + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'tools/call', $params); + } + + public static function fromRequest(Request $request): self + { + if ('tools/call' !== $request->method) { + throw new InvalidArgumentException('Request is not a call tool request'); + } + + $params = $request->params ?? []; + + if (!isset($params['name']) || !\is_string($params['name'])) { + throw new InvalidArgumentException('Missing or invalid "name" parameter for tools/call.'); + } + + $arguments = $params['arguments'] ?? []; + + if ($arguments instanceof \stdClass) { + $arguments = (array) $arguments; + } + + if (!\is_array($arguments)) { + throw new InvalidArgumentException('Parameter "arguments" must be an array.'); + } + + return new self( + $request->id, + $params['name'], + $arguments, + $params['_meta'] ?? null + ); + } +} diff --git a/src/Schema/Request/CompletionCompleteRequest.php b/src/Schema/Request/CompletionCompleteRequest.php new file mode 100644 index 0000000..3581513 --- /dev/null +++ b/src/Schema/Request/CompletionCompleteRequest.php @@ -0,0 +1,73 @@ + + */ +class CompletionCompleteRequest extends Request +{ + /** + * @param PromptReference|ResourceReference $ref the prompt or resource to complete + * @param array{ name: string, value: string } $argument the argument to complete + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly PromptReference|ResourceReference $ref, + public readonly array $argument, + public readonly ?array $_meta = null, + ) { + $params = [ + 'ref' => $this->ref, + 'argument' => $this->argument, + ]; + + if (null !== $this->_meta) { + $params['_meta'] = $this->_meta; + } + + parent::__construct($id, 'completion/complete', $params); + } + + public static function fromRequest(Request $request): self + { + if ('completion/complete' !== $request->method) { + throw new InvalidArgumentException('Request is not a completion/complete request'); + } + + $params = $request->params; + + if (!isset($params['ref']) || !\is_array($params['ref'])) { + throw new InvalidArgumentException('Missing or invalid "ref" parameter for completion/complete.'); + } + + $ref = match ($params['ref']['type'] ?? null) { + 'ref/prompt' => new PromptReference($params['ref']['name']), + 'ref/resource' => new ResourceReference($params['ref']['uri']), + default => throw new InvalidArgumentException('Invalid "ref" parameter for completion/complete.'), + }; + + if (!isset($params['argument']) || !\is_array($params['argument'])) { + throw new InvalidArgumentException('Missing or invalid "argument" parameter for completion/complete.'); + } + + return new self($request->id, $ref, $params['argument'], $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php new file mode 100644 index 0000000..5f44e44 --- /dev/null +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -0,0 +1,92 @@ + + */ +class CreateSamplingMessageRequest extends Request +{ + /** + * @param SamplingMessage[] $messages the messages to send to the model + * @param int $maxTokens The maximum number of tokens to sample, as requested by the server. + * The client MAY choose to sample fewer tokens than requested. + * @param ModelPreferences|null $preferences The server's preferences for which model to select. The client MAY + * ignore these preferences. + * @param string|null $systemPrompt An optional system prompt the server wants to use for sampling. The + * client MAY modify or omit this prompt. + * @param string|null $includeContext A request to include context from one or more MCP servers (including + * the caller), to be attached to the prompt. The client MAY ignore this request. + * + * Allowed values: "none", "thisServer", "allServers" + * @param float|null $temperature The temperature to use for sampling. The client MAY ignore this request. + * @param string[]|null $stopSequences A list of sequences to stop sampling at. The client MAY ignore this request. + * @param ?array $metadata Optional metadata to pass through to the LLM provider. The format of + * this metadata is provider-specific. + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly array $messages, + public readonly int $maxTokens, + public readonly ?ModelPreferences $preferences = null, + public readonly ?string $systemPrompt = null, + public readonly ?string $includeContext = null, + public readonly ?float $temperature = null, + public readonly ?array $stopSequences = null, + public readonly ?array $metadata = null, + public readonly ?array $_meta = null, + ) { + $params = [ + 'messages' => $this->messages, + 'maxTokens' => $this->maxTokens, + ]; + + if (null !== $this->preferences) { + $params['preferences'] = $this->preferences; + } + + if (null !== $this->systemPrompt) { + $params['systemPrompt'] = $this->systemPrompt; + } + + if (null !== $this->includeContext) { + $params['includeContext'] = $this->includeContext; + } + + if (null !== $this->temperature) { + $params['temperature'] = $this->temperature; + } + + if (null !== $this->stopSequences) { + $params['stopSequences'] = $this->stopSequences; + } + + if (null !== $this->metadata) { + $params['metadata'] = $this->metadata; + } + + if (null !== $this->_meta) { + $params['_meta'] = $this->_meta; + } + + parent::__construct($id, 'sampling/createMessage', $params); + } +} diff --git a/src/Schema/Request/GetPromptRequest.php b/src/Schema/Request/GetPromptRequest.php new file mode 100644 index 0000000..90ea75e --- /dev/null +++ b/src/Schema/Request/GetPromptRequest.php @@ -0,0 +1,68 @@ + + */ +class GetPromptRequest extends Request +{ + /** + * @param string|int $id the ID of the request to cancel + * @param string $name the name of the prompt to get + * @param array|null $arguments the arguments to pass to the prompt + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly string $name, + public readonly ?array $arguments = null, + public readonly ?array $_meta = null, + ) { + $params = ['name' => $name]; + + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + if (null !== $arguments) { + $params['arguments'] = (object) $arguments; + } + + parent::__construct($id, 'prompts/get', $params); + } + + public static function fromRequest(Request $request): self + { + if ('prompts/get' !== $request->method) { + throw new InvalidArgumentException('Request is not a prompts/get request'); + } + + $params = $request->params; + + if (!isset($params['name']) || !\is_string($params['name']) || empty($params['name'])) { + throw new InvalidArgumentException('Missing or invalid "name" parameter for prompts/get.'); + } + + $arguments = $params['arguments'] ?? new \stdClass(); + if (!\is_array($arguments) && !$arguments instanceof \stdClass) { + throw new InvalidArgumentException('Parameter "arguments" must be an object/array for prompts/get.'); + } + + return new self($request->id, $params['name'], $arguments, $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/InitializeRequest.php b/src/Schema/Request/InitializeRequest.php new file mode 100644 index 0000000..0bd4a14 --- /dev/null +++ b/src/Schema/Request/InitializeRequest.php @@ -0,0 +1,77 @@ + + */ +class InitializeRequest extends Request +{ + /** + * @param string|int $id the ID of the request + * @param string $protocolVersion The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + * @param ClientCapabilities $capabilities the capabilities of the client + * @param Implementation $clientInfo information about the client + * @param ?array $_meta additional metadata about the request + */ + public function __construct( + string|int $id, + public readonly string $protocolVersion, + public readonly ClientCapabilities $capabilities, + public readonly Implementation $clientInfo, + public readonly ?array $_meta = null, + ) { + $params = [ + 'protocolVersion' => $this->protocolVersion, + 'capabilities' => $this->capabilities, + 'clientInfo' => $this->clientInfo, + ]; + + if (null !== $this->_meta) { + $params['_meta'] = $this->_meta; + } + + parent::__construct($id, 'initialize', $params); + } + + public static function fromRequest(Request $request): self + { + if ('initialize' !== $request->method) { + throw new InvalidArgumentException('Request is not an initialize request'); + } + + $params = $request->params; + + if (!isset($params['protocolVersion'])) { + throw new InvalidArgumentException('protocolVersion is required'); + } + + if (!isset($params['capabilities'])) { + throw new InvalidArgumentException('capabilities is required'); + } + $capabilities = ClientCapabilities::fromArray($params['capabilities']); + + if (!isset($params['clientInfo'])) { + throw new InvalidArgumentException('clientInfo is required'); + } + $clientInfo = Implementation::fromArray($params['clientInfo']); + + return new self($request->id, $params['protocolVersion'], $capabilities, $clientInfo, $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/ListPromptsRequest.php b/src/Schema/Request/ListPromptsRequest.php new file mode 100644 index 0000000..48afbbd --- /dev/null +++ b/src/Schema/Request/ListPromptsRequest.php @@ -0,0 +1,54 @@ + + */ +class ListPromptsRequest extends Request +{ + /** + * If provided, the server should return results starting after this cursor. + * + * @param string|null $cursor an opaque token representing the current pagination position + * @param array|null $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly ?string $cursor = null, + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $cursor) { + $params['cursor'] = $cursor; + } + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'prompts/list', $params); + } + + public static function fromRequest(Request $request): self + { + if ('prompts/list' !== $request->method) { + throw new InvalidArgumentException('Request is not a prompts/list request'); + } + + return new self($request->id, $request->params['cursor'] ?? null, $request->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/ListResourceTemplatesRequest.php b/src/Schema/Request/ListResourceTemplatesRequest.php new file mode 100644 index 0000000..327bda2 --- /dev/null +++ b/src/Schema/Request/ListResourceTemplatesRequest.php @@ -0,0 +1,54 @@ + + */ +class ListResourceTemplatesRequest extends Request +{ + /** + * @param string|null $cursor An opaque token representing the current pagination position. + * + * If provided, the server should return results starting after this cursor. + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly ?string $cursor = null, + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $cursor) { + $params['cursor'] = $cursor; + } + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'resources/templates/list', $params); + } + + public static function fromRequest(Request $request): self + { + if ('resources/templates/list' !== $request->method) { + throw new InvalidArgumentException('Request is not a list resource templates request'); + } + + return new self($request->id, $request->params['cursor'] ?? null, $request->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/ListResourcesRequest.php b/src/Schema/Request/ListResourcesRequest.php new file mode 100644 index 0000000..e698b02 --- /dev/null +++ b/src/Schema/Request/ListResourcesRequest.php @@ -0,0 +1,54 @@ + + */ +class ListResourcesRequest extends Request +{ + /** + * @param string|null $cursor An opaque token representing the current pagination position. + * + * If provided, the server should return results starting after this cursor. + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly ?string $cursor = null, + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $cursor) { + $params['cursor'] = $cursor; + } + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'resources/list', $params); + } + + public static function fromRequest(Request $request): self + { + if ('resources/list' !== $request->method) { + throw new InvalidArgumentException('Request is not a list resources request'); + } + + return new self($request->id, $request->params['cursor'] ?? null, $request->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/ListRootsRequest.php b/src/Schema/Request/ListRootsRequest.php new file mode 100644 index 0000000..7415a98 --- /dev/null +++ b/src/Schema/Request/ListRootsRequest.php @@ -0,0 +1,43 @@ + + */ +class ListRootsRequest extends Request +{ + /** + * @param ?array $_meta + */ + public function __construct( + string|int $id, + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'roots/list', $params); + } +} diff --git a/src/Schema/Request/ListToolsRequest.php b/src/Schema/Request/ListToolsRequest.php new file mode 100644 index 0000000..d5f3eaf --- /dev/null +++ b/src/Schema/Request/ListToolsRequest.php @@ -0,0 +1,54 @@ + + */ +class ListToolsRequest extends Request +{ + /** + * @param string|null $cursor An opaque token representing the current pagination position. + * + * If provided, the server should return results starting after this cursor. + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly ?string $cursor = null, + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $cursor) { + $params['cursor'] = $cursor; + } + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'tools/list', $params); + } + + public static function fromRequest(Request $request): self + { + if ('tools/list' !== $request->method) { + throw new InvalidArgumentException('Request is not a list tools request'); + } + + return new self($request->id, $request->params['cursor'] ?? null, $request->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/PingRequest.php b/src/Schema/Request/PingRequest.php new file mode 100644 index 0000000..8db1a53 --- /dev/null +++ b/src/Schema/Request/PingRequest.php @@ -0,0 +1,48 @@ + + */ +class PingRequest extends Request +{ + /** + * @param ?array $_meta + */ + public function __construct( + string|int $id, + public readonly ?array $_meta = null, + ) { + $params = []; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'ping', $params); + } + + public static function fromRequest(Request $request): self + { + if ('ping' !== $request->method) { + throw new InvalidArgumentException('Request is not a ping request'); + } + + return new self($request->id, $request->params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/ReadResourceRequest.php b/src/Schema/Request/ReadResourceRequest.php new file mode 100644 index 0000000..5e90cd4 --- /dev/null +++ b/src/Schema/Request/ReadResourceRequest.php @@ -0,0 +1,58 @@ + + */ +class ReadResourceRequest extends Request +{ + /** + * @param string $uri the URI of the resource to read + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly string $uri, + public readonly ?array $_meta = null, + ) { + $params = [ + 'uri' => $uri, + ]; + + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'resources/read', $params); + } + + public static function fromRequest(Request $request): self + { + if ('resources/read' !== $request->method) { + throw new InvalidArgumentException('Request is not a read resource request'); + } + + $params = $request->params; + + if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { + throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/read.'); + } + + return new self($request->id, $params['uri'], $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/ResourceSubscribeRequest.php b/src/Schema/Request/ResourceSubscribeRequest.php new file mode 100644 index 0000000..01253fa --- /dev/null +++ b/src/Schema/Request/ResourceSubscribeRequest.php @@ -0,0 +1,56 @@ + + */ +class ResourceSubscribeRequest extends Request +{ + /** + * @param string $uri the URI of the resource to subscribe to + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly string $uri, + public readonly ?array $_meta = null, + ) { + $params = ['uri' => $uri]; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'resources/subscribe', $params); + } + + public static function fromRequest(Request $request): self + { + if ('resources/subscribe' !== $request->method) { + throw new InvalidArgumentException('Request is not a resource subscribe request'); + } + + $params = $request->params; + + if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { + throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/subscribe.'); + } + + return new self($request->id, $params['uri'], $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/ResourceUnsubscribeRequest.php b/src/Schema/Request/ResourceUnsubscribeRequest.php new file mode 100644 index 0000000..1257884 --- /dev/null +++ b/src/Schema/Request/ResourceUnsubscribeRequest.php @@ -0,0 +1,56 @@ + + */ +class ResourceUnsubscribeRequest extends Request +{ + /** + * @param string $uri the URI of the resource to unsubscribe from + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly string $uri, + public readonly ?array $_meta = null, + ) { + $params = ['uri' => $uri]; + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'resources/unsubscribe', $params); + } + + public static function fromRequest(Request $request): self + { + if ('resources/unsubscribe' !== $request->method) { + throw new InvalidArgumentException('Request is not a resource unsubscribe request'); + } + + $params = $request->params; + + if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { + throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/unsubscribe.'); + } + + return new self($request->id, $params['uri'], $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Request/SetLogLevelRequest.php b/src/Schema/Request/SetLogLevelRequest.php new file mode 100644 index 0000000..bf785a1 --- /dev/null +++ b/src/Schema/Request/SetLogLevelRequest.php @@ -0,0 +1,61 @@ + + */ +class SetLogLevelRequest extends Request +{ + /** + * @param LoggingLevel $level The level of logging that the client wants to receive from the server. The server + * should send all logs at this level and higher (i.e., more severe) to the client as + * notifications/message. + * @param ?array $_meta optional metadata to include in the request + */ + public function __construct( + string|int $id, + public readonly LoggingLevel $level, + public readonly ?array $_meta = null, + ) { + $params = [ + 'level' => $level->value, + ]; + + if (null !== $_meta) { + $params['_meta'] = $_meta; + } + + parent::__construct($id, 'logging/setLevel', $params); + } + + public static function fromRequest(Request $request): self + { + if ('logging/setLevel' !== $request->method) { + throw new InvalidArgumentException('Request is not a logging/setLevel request'); + } + + $params = $request->params; + + if (!isset($params['level']) || !\is_string($params['level']) || empty($params['level'])) { + throw new InvalidArgumentException('Missing or invalid "level" parameter for logging/setLevel.'); + } + + return new self($request->id, LoggingLevel::from($params['level']), $params['_meta'] ?? null); + } +} diff --git a/src/Schema/Resource.php b/src/Schema/Resource.php new file mode 100644 index 0000000..ca6fa11 --- /dev/null +++ b/src/Schema/Resource.php @@ -0,0 +1,124 @@ + + */ +class Resource implements \JsonSerializable +{ + /** + * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. + */ + private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; + + /** + * URI pattern regex - requires a valid scheme, followed by colon and optional path. + * Example patterns: config://, file://path, db://table, etc. + */ + private const URI_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]*$/'; + + /** + * @param string $uri the URI of this resource + * @param string $name A human-readable name for this resource. This can be used by clients to populate UI elements. + * @param string|null $description A description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + * @param string|null $mimeType the MIME type of this resource, if known + * @param Annotations|null $annotations optional annotations for the client + * @param int|null $size The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + public function __construct( + public readonly string $uri, + public readonly string $name, + public readonly ?string $description = null, + public readonly ?string $mimeType = null, + public readonly ?Annotations $annotations = null, + public readonly ?int $size = null, + ) { + if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { + throw new InvalidArgumentException('Invalid resource name: must contain only alphanumeric characters, underscores, and hyphens.'); + } + if (!preg_match(self::URI_PATTERN, $uri)) { + throw new InvalidArgumentException('Invalid resource URI: must be a valid URI with a scheme and optional path.'); + } + } + + /** + * @param ResourceData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Invalid or missing "uri" in Resource data.'); + } + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in Resource data.'); + } + + return new self( + uri: $data['uri'], + name: $data['name'], + description: $data['description'] ?? null, + mimeType: $data['mimeType'] ?? null, + annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + size: isset($data['size']) ? (int) $data['size'] : null + ); + } + + /** + * @return array{ + * uri: string, + * name: string, + * description?: string, + * mimeType?: string, + * annotations?: Annotations, + * size?: int, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'uri' => $this->uri, + 'name' => $this->name, + ]; + if (null !== $this->description) { + $data['description'] = $this->description; + } + if (null !== $this->mimeType) { + $data['mimeType'] = $this->mimeType; + } + if (null !== $this->annotations) { + $data['annotations'] = $this->annotations; + } + if (null !== $this->size) { + $data['size'] = $this->size; + } + + return $data; + } +} diff --git a/src/Schema/ResourceReference.php b/src/Schema/ResourceReference.php new file mode 100644 index 0000000..c861c7e --- /dev/null +++ b/src/Schema/ResourceReference.php @@ -0,0 +1,44 @@ + + */ +class ResourceReference implements \JsonSerializable +{ + public string $type = 'ref/resource'; + + /** + * @param string $uri the URI or URI template of the resource + */ + public function __construct( + public readonly string $uri, + ) { + } + + /** + * @return array{ + * type: string, + * uri: string, + * } + */ + public function jsonSerialize(): array + { + return [ + 'type' => $this->type, + 'uri' => $this->uri, + ]; + } +} diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php new file mode 100644 index 0000000..796b3e2 --- /dev/null +++ b/src/Schema/ResourceTemplate.php @@ -0,0 +1,114 @@ + + */ +class ResourceTemplate implements \JsonSerializable +{ + /** + * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. + */ + private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; + + /** + * URI Template pattern regex - requires a valid scheme, followed by colon and path with at least one placeholder. + * Example patterns: config://{key}, file://{path}/contents.txt, db://{table}/{id}, etc. + */ + private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*{[^{}]+}.*/'; + + /** + * @param string $uriTemplate a URI template (according to RFC 6570) that can be used to construct resource URIs + * @param string $name A human-readable name for the type of resource this template refers to. This can be used by clients to populate UI elements. + * @param string|null $description This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + * @param string|null $mimeType The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + * @param Annotations|null $annotations optional annotations for the client + */ + public function __construct( + public readonly string $uriTemplate, + public readonly string $name, + public readonly ?string $description = null, + public readonly ?string $mimeType = null, + public readonly ?Annotations $annotations = null, + ) { + if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { + throw new InvalidArgumentException('Invalid resource name: must contain only alphanumeric characters, underscores, and hyphens.'); + } + if (!preg_match(self::URI_TEMPLATE_PATTERN, $uriTemplate)) { + throw new InvalidArgumentException('Invalid URI template: must be a valid URI template with at least one placeholder.'); + } + } + + /** + * @param ResourceTemplateData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['uriTemplate']) || !\is_string($data['uriTemplate'])) { + throw new InvalidArgumentException('Invalid or missing "uriTemplate" in ResourceTemplate data.'); + } + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in ResourceTemplate data.'); + } + + return new self( + uriTemplate: $data['uriTemplate'], + name: $data['name'], + description: $data['description'] ?? null, + mimeType: $data['mimeType'] ?? null, + annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + ); + } + + /** + * @return array{ + * uriTemplate: string, + * name: string, + * description?: string, + * mimeType?: string, + * annotations?: Annotations, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'uriTemplate' => $this->uriTemplate, + 'name' => $this->name, + ]; + if (null !== $this->description) { + $data['description'] = $this->description; + } + if (null !== $this->mimeType) { + $data['mimeType'] = $this->mimeType; + } + if (null !== $this->annotations) { + $data['annotations'] = $this->annotations; + } + + return $data; + } +} diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php new file mode 100644 index 0000000..ee98bd6 --- /dev/null +++ b/src/Schema/Result/CallToolResult.php @@ -0,0 +1,126 @@ + + */ +class CallToolResult implements ResultInterface +{ + /** + * Create a new CallToolResult. + * + * @param array $content The content of the tool result + * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + */ + public function __construct( + public readonly array $content, + public readonly bool $isError = false, + ) { + foreach ($this->content as $item) { + if (!$item instanceof Content) { + throw new InvalidArgumentException('Content must be an array of Content objects.'); + } + } + } + + /** + * Create a new CallToolResult with success status. + * + * @param array $content The content of the tool result + */ + public static function success(array $content): self + { + return new self($content, false); + } + + /** + * Create a new CallToolResult with error status. + * + * @param array $content The content of the tool result + */ + public static function error(array $content): self + { + return new self($content, true); + } + + /** + * @param array{ + * content: array, + * isError?: bool, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['content']) || !\is_array($data['content'])) { + throw new InvalidArgumentException('Missing or invalid "content" array in CallToolResult data.'); + } + + $contents = []; + + foreach ($data['content'] as $item) { + $contents[] = match ($item['type'] ?? null) { + 'text' => TextContent::fromArray($item), + 'image' => ImageContent::fromArray($item), + 'audio' => AudioContent::fromArray($item), + 'resource' => EmbeddedResource::fromArray($item), + default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $item['type'] ?? null)), + }; + } + + return new self($contents, $data['isError'] ?? false); + } + + public static function fromResponse(Response $response): self + { + return self::fromArray($response->result); + } + + /** + * @return array{ + * content: array, + * isError: bool, + * } + */ + public function jsonSerialize(): array + { + return [ + 'content' => $this->content, + 'isError' => $this->isError, + ]; + } +} diff --git a/src/Schema/Result/CompletionCompleteResult.php b/src/Schema/Result/CompletionCompleteResult.php new file mode 100644 index 0000000..813e8ab --- /dev/null +++ b/src/Schema/Result/CompletionCompleteResult.php @@ -0,0 +1,63 @@ + + */ +class CompletionCompleteResult implements ResultInterface +{ + /** + * @param string[] $values An array of completion values. Must not exceed 100 items. + * @param int|null $total The total number of completion options available. This can exceed the number of values actually sent in the response. + * @param bool|null $hasMore indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown + */ + public function __construct( + public readonly array $values, + public readonly ?int $total = null, + public readonly ?bool $hasMore = null, + ) { + if (\count($this->values) > 100) { + throw new InvalidArgumentException('Values must not exceed 100 items'); + } + } + + /** + * @return array{ + * completion: array{ + * values: string[], + * total?: int, + * hasMore?: bool, + * } + * } + */ + public function jsonSerialize(): array + { + $completion = [ + 'values' => $this->values, + ]; + + if (null !== $this->total) { + $completion['total'] = $this->total; + } + if (null !== $this->hasMore) { + $completion['hasMore'] = $this->hasMore; + } + + return ['completion' => $completion]; + } +} diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php new file mode 100644 index 0000000..b9e87c1 --- /dev/null +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -0,0 +1,65 @@ + + */ +class CreateSamplingMessageResult implements ResultInterface +{ + /** + * @param Role $role the role of the message + * @param TextContent|ImageContent|AudioContent $content the content of the message + * @param string $model the name of the model that generated the message + * @param string|null $stopReason the reason why sampling stopped, if known + */ + public function __construct( + public readonly Role $role, + public readonly TextContent|ImageContent|AudioContent $content, + public readonly string $model, + public readonly ?string $stopReason = null, + ) { + } + + /** + * @return array{ + * role: string, + * content: TextContent|ImageContent|AudioContent, + * model: string, + * stopReason?: string, + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'role' => $this->role->value, + 'content' => $this->content, + 'model' => $this->model, + ]; + + if (null !== $this->stopReason) { + $result['stopReason'] = $this->stopReason; + } + + return $result; + } +} diff --git a/src/Schema/Result/EmptyResult.php b/src/Schema/Result/EmptyResult.php new file mode 100644 index 0000000..a76869c --- /dev/null +++ b/src/Schema/Result/EmptyResult.php @@ -0,0 +1,39 @@ + + */ +class EmptyResult implements ResultInterface +{ + /** + * Create a new EmptyResult. + */ + public function __construct() + { + } + + public static function fromArray(): self + { + return new self(); + } + + public function jsonSerialize(): \stdClass + { + return new \stdClass(); + } +} diff --git a/src/Schema/Result/GetPromptResult.php b/src/Schema/Result/GetPromptResult.php new file mode 100644 index 0000000..d70d34a --- /dev/null +++ b/src/Schema/Result/GetPromptResult.php @@ -0,0 +1,86 @@ + + */ +class GetPromptResult implements ResultInterface +{ + /** + * Create a new GetPromptResult. + * + * @param PromptMessage[] $messages The messages in the prompt + * @param string|null $description Optional description of the prompt + */ + public function __construct( + public readonly array $messages, + public readonly ?string $description = null, + ) { + foreach ($this->messages as $message) { + if (!$message instanceof PromptMessage) { + throw new InvalidArgumentException('Messages must be an array of PromptMessage objects.'); + } + } + } + + /** + * @param array{ + * messages: array, + * description?: string, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['messages']) || !\is_array($data['messages'])) { + throw new InvalidArgumentException('Missing or invalid "messages" array in GetPromptResult data.'); + } + + $messages = []; + foreach ($data['messages'] as $message) { + $messages[] = PromptMessage::fromArray($message); + } + + return new self($messages, $data['description'] ?? null); + } + + public static function fromResponse(Response $response): self + { + return self::fromArray($response->result); + } + + /** + * @return array{ + * messages: array, + * description?: string, + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'messages' => $this->messages, + ]; + + if (null !== $this->description) { + $result['description'] = $this->description; + } + + return $result; + } +} diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php new file mode 100644 index 0000000..6c3bede --- /dev/null +++ b/src/Schema/Result/InitializeResult.php @@ -0,0 +1,103 @@ + + */ +class InitializeResult implements ResultInterface +{ + /** + * Create a new InitializeResult. + * + * @param ServerCapabilities $capabilities the capabilities of the server + * @param Implementation $serverInfo information about the server + * @param string|null $instructions Instructions describing how to use the server and its features. This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + * @param array|null $_meta optional _meta field + */ + public function __construct( + public readonly ServerCapabilities $capabilities, + public readonly Implementation $serverInfo, + public readonly ?string $instructions = null, + public readonly ?array $_meta = null, + ) { + } + + /** + * @param array{ + * protocolVersion: string, + * capabilities: array, + * serverInfo: array, + * instructions?: string, + * _meta?: array, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['protocolVersion']) || !\is_string($data['protocolVersion'])) { + throw new InvalidArgumentException('Missing or invalid "protocolVersion".'); + } + if (!isset($data['capabilities']) || !\is_array($data['capabilities'])) { + throw new InvalidArgumentException('Missing or invalid "capabilities".'); + } + if (!isset($data['serverInfo']) || !\is_array($data['serverInfo'])) { + throw new InvalidArgumentException('Missing or invalid "serverInfo".'); + } + + return new self( + ServerCapabilities::fromArray($data['capabilities']), + Implementation::fromArray($data['serverInfo']), + $data['instructions'] ?? null, + $data['_meta'] ?? null + ); + } + + public static function fromResponse(Response $response): self + { + return self::fromArray($response->result); + } + + /** + * @return array{ + * protocolVersion: string, + * capabilities: ServerCapabilities, + * serverInfo: Implementation, + * instructions?: string, + * _meta?: array, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'protocolVersion' => Constants::JSONRPC_VERSION, + 'capabilities' => $this->capabilities, + 'serverInfo' => $this->serverInfo, + ]; + if (null !== $this->instructions) { + $data['instructions'] = $this->instructions; + } + if (null !== $this->_meta) { + $data['_meta'] = $this->_meta; + } + + return $data; + } +} diff --git a/src/Schema/Result/ListPromptsResult.php b/src/Schema/Result/ListPromptsResult.php new file mode 100644 index 0000000..99cb9e5 --- /dev/null +++ b/src/Schema/Result/ListPromptsResult.php @@ -0,0 +1,81 @@ + + */ +class ListPromptsResult implements ResultInterface +{ + /** + * @param array $prompts the list of prompt definitions + * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. + * + * If present, there may be more results available. + */ + public function __construct( + public readonly array $prompts, + public readonly ?string $nextCursor = null, + ) { + } + + /** + * @param array{ + * prompts: array, + * nextCursor?: string, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['prompts']) || !\is_array($data['prompts'])) { + throw new InvalidArgumentException('Missing or invalid "prompts" array in ListPromptsResult data.'); + } + + return new self( + array_map(fn (array $prompt) => Prompt::fromArray($prompt), $data['prompts']), + $data['nextCursor'] ?? null + ); + } + + public static function fromResponse(Response $response): self + { + return self::fromArray($response->result); + } + + /** + * @return array{ + * prompts: array, + * nextCursor?: string, + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'prompts' => $this->prompts, + ]; + + if ($this->nextCursor) { + $result['nextCursor'] = $this->nextCursor; + } + + return $result; + } +} diff --git a/src/Schema/Result/ListResourceTemplatesResult.php b/src/Schema/Result/ListResourceTemplatesResult.php new file mode 100644 index 0000000..1c9a57f --- /dev/null +++ b/src/Schema/Result/ListResourceTemplatesResult.php @@ -0,0 +1,81 @@ + + */ +class ListResourceTemplatesResult implements ResultInterface +{ + /** + * @param array $resourceTemplates the list of resource template definitions + * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. + * + * If present, there may be more results available. + */ + public function __construct( + public readonly array $resourceTemplates, + public readonly ?string $nextCursor = null, + ) { + } + + /** + * @param array{ + * resourceTemplates: array, + * nextCursor?: string + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['resourceTemplates']) || !\is_array($data['resourceTemplates'])) { + throw new InvalidArgumentException('Missing or invalid "resourceTemplates" array in ListResourceTemplatesResult data.'); + } + + return new self( + array_map(fn (array $resourceTemplate) => ResourceTemplate::fromArray($resourceTemplate), $data['resourceTemplates']), + $data['nextCursor'] ?? null + ); + } + + public static function fromResponse(Response $response): self + { + return self::fromArray($response->result); + } + + /** + * @return array{ + * resourceTemplates: array, + * nextCursor?: string, + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'resourceTemplates' => $this->resourceTemplates, + ]; + + if ($this->nextCursor) { + $result['nextCursor'] = $this->nextCursor; + } + + return $result; + } +} diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php new file mode 100644 index 0000000..97559da --- /dev/null +++ b/src/Schema/Result/ListResourcesResult.php @@ -0,0 +1,81 @@ + + */ +class ListResourcesResult implements ResultInterface +{ + /** + * @param array $resources the list of resource definitions + * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. + * + * If present, there may be more results available. + */ + public function __construct( + public readonly array $resources, + public readonly ?string $nextCursor = null, + ) { + } + + /** + * @param array{ + * resources: array, + * nextCursor?: string, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['resources']) || !\is_array($data['resources'])) { + throw new InvalidArgumentException('Missing or invalid "resources" array in ListResourcesResult data.'); + } + + return new self( + array_map(fn (array $resource) => Resource::fromArray($resource), $data['resources']), + $data['nextCursor'] ?? null + ); + } + + public static function fromResponse(Response $response): self + { + return self::fromArray($response->result); + } + + /** + * @return array{ + * resources: array, + * nextCursor?: string, + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'resources' => $this->resources, + ]; + + if (null !== $this->nextCursor) { + $result['nextCursor'] = $this->nextCursor; + } + + return $result; + } +} diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php new file mode 100644 index 0000000..3a04fa3 --- /dev/null +++ b/src/Schema/Result/ListRootsResult.php @@ -0,0 +1,54 @@ + + */ +class ListRootsResult implements ResultInterface +{ + /** + * @param Root[] $roots an array of root URIs + * @param ?array $_meta optional metadata about the result + */ + public function __construct( + public readonly array $roots, + public readonly ?array $_meta = null, + ) { + } + + /** + * @return array{ + * roots: Root[], + * _meta?: ?array + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'roots' => $this->roots, + ]; + + if (null !== $this->_meta) { + $result['_meta'] = $this->_meta; + } + + return $result; + } +} diff --git a/src/Schema/Result/ListToolsResult.php b/src/Schema/Result/ListToolsResult.php new file mode 100644 index 0000000..d499201 --- /dev/null +++ b/src/Schema/Result/ListToolsResult.php @@ -0,0 +1,81 @@ + + */ +class ListToolsResult implements ResultInterface +{ + /** + * @param array $tools the list of tool definitions + * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. + * + * If present, there may be more results available. + */ + public function __construct( + public readonly array $tools, + public readonly ?string $nextCursor = null, + ) { + } + + /** + * @param array{ + * tools: array, + * nextCursor?: string, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['tools']) || !\is_array($data['tools'])) { + throw new InvalidArgumentException('Missing or invalid "tools" array in ListToolsResult data.'); + } + + return new self( + array_map(fn (array $tool) => Tool::fromArray($tool), $data['tools']), + $data['nextCursor'] ?? null + ); + } + + public static function fromResponse(Response $response): self + { + return self::fromArray($response->result); + } + + /** + * @return array{ + * tools: array, + * nextCursor?: string, + * } + */ + public function jsonSerialize(): array + { + $result = [ + 'tools' => $this->tools, + ]; + + if ($this->nextCursor) { + $result['nextCursor'] = $this->nextCursor; + } + + return $result; + } +} diff --git a/src/Schema/Result/ReadResourceResult.php b/src/Schema/Result/ReadResourceResult.php new file mode 100644 index 0000000..ce97cd1 --- /dev/null +++ b/src/Schema/Result/ReadResourceResult.php @@ -0,0 +1,81 @@ + + */ +class ReadResourceResult implements ResultInterface +{ + /** + * Create a new ReadResourceResult. + * + * @param array $contents The contents of the resource + */ + public function __construct( + public readonly array $contents, + ) { + } + + /** + * @param array{ + * contents: array, + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['contents']) || !\is_array($data['contents'])) { + throw new InvalidArgumentException('Missing or invalid "contents" array in ReadResourceResult data.'); + } + + $contents = []; + foreach ($data['contents'] as $content) { + if (isset($content['text'])) { + $contents[] = TextResourceContents::fromArray($content); + } elseif (isset($content['blob'])) { + $contents[] = BlobResourceContents::fromArray($content); + } else { + throw new InvalidArgumentException('Invalid content type in ReadResourceResult data: '.json_encode($content)); + } + } + + return new self($contents); + } + + public static function fromResponse(Response $response): self + { + return self::fromArray($response->result); + } + + /** + * @return array{ + * contents: array, + * } + */ + public function jsonSerialize(): array + { + return [ + 'contents' => $this->contents, + ]; + } +} diff --git a/src/Schema/Root.php b/src/Schema/Root.php new file mode 100644 index 0000000..5f6862d --- /dev/null +++ b/src/Schema/Root.php @@ -0,0 +1,72 @@ + + */ +class Root implements \JsonSerializable +{ + private const URI_PATTERN = '/^file:\/\/.*$/'; + + /** + * @param string $uri The URI identifying the root. This *must* start with file:// for now. + * + * This restriction may be relaxed in future versions of the protocol to allow other URI schemes. + * @param string|null $name An optional name for the root. + * + * This can be used to provide a human-readable identifier for the root, which may be useful for + * display purposes or for referencing the root in other parts of the application. + */ + public function __construct( + public readonly string $uri, + public readonly ?string $name = null, + ) { + if (!preg_match(self::URI_PATTERN, $this->uri)) { + throw new InvalidArgumentException(\sprintf('Root URI must start with "file://". Given: "%s".', $this->uri)); + } + } + + /** + * @param RootData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Invalid or missing "uri" in Root data.'); + } + + return new self($data['uri'], $data['name'] ?? null); + } + + /** + * @return RootData + */ + public function jsonSerialize(): array + { + $data = ['uri' => $this->uri]; + if (null !== $this->name) { + $data['name'] = $this->name; + } + + return $data; + } +} diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php new file mode 100644 index 0000000..fffc265 --- /dev/null +++ b/src/Schema/ServerCapabilities.php @@ -0,0 +1,164 @@ + + */ +class ServerCapabilities implements \JsonSerializable +{ + /** + * @param ?bool $tools server exposes callable tools + * @param ?bool $toolsListChanged server supports list changed notifications for tools + * @param ?bool $resources server provides readable resources + * @param ?bool $resourcesSubscribe server supports subscribing to changes in the list of resources + * @param ?bool $resourcesListChanged server supports list changed notifications for resources + * @param ?bool $prompts server provides prompts templates + * @param ?bool $promptsListChanged server supports list changed notifications for prompts + * @param ?bool $logging server emits structured log messages + * @param ?bool $completions Server supports argument autocompletion + * @param ?array $experimental experimental, non-standard features that the server supports + */ + public function __construct( + public readonly ?bool $tools = true, + public readonly ?bool $toolsListChanged = false, + public readonly ?bool $resources = true, + public readonly ?bool $resourcesSubscribe = false, + public readonly ?bool $resourcesListChanged = false, + public readonly ?bool $prompts = true, + public readonly ?bool $promptsListChanged = false, + public readonly ?bool $logging = false, + public readonly ?bool $completions = false, + public readonly ?array $experimental = null, + ) { + } + + /** + * @param array{ + * logging?: mixed, + * completions?: mixed, + * prompts?: array{listChanged?: bool}|object, + * resources?: array{listChanged?: bool, subscribe?: bool}|object, + * tools?: object, + * experimental?: array, + * } $data + */ + public static function fromArray(array $data): self + { + $loggingEnabled = isset($data['logging']); + $completionsEnabled = isset($data['completions']); + $toolsEnabled = isset($data['tools']); + $promptsEnabled = isset($data['prompts']); + $resourcesEnabled = isset($data['resources']); + + $promptsListChanged = null; + if (isset($data['prompts'])) { + if (\is_array($data['prompts']) && \array_key_exists('listChanged', $data['prompts'])) { + $promptsListChanged = (bool) $data['prompts']['listChanged']; + } elseif (\is_object($data['prompts']) && property_exists($data['prompts'], 'listChanged')) { + $promptsListChanged = (bool) $data['prompts']->listChanged; + } + } + + $resourcesSubscribe = null; + $resourcesListChanged = null; + if (isset($data['resources'])) { + if (\is_array($data['resources']) && \array_key_exists('subscribe', $data['resources'])) { + $resourcesSubscribe = (bool) $data['resources']['subscribe']; + } elseif (\is_object($data['resources']) && property_exists($data['resources'], 'subscribe')) { + $resourcesSubscribe = (bool) $data['resources']->subscribe; + } + if (\is_array($data['resources']) && \array_key_exists('listChanged', $data['resources'])) { + $resourcesListChanged = (bool) $data['resources']['listChanged']; + } elseif (\is_object($data['resources']) && property_exists($data['resources'], 'listChanged')) { + $resourcesListChanged = (bool) $data['resources']->listChanged; + } + } + + $toolsListChanged = null; + if (isset($data['tools'])) { + if (\is_array($data['tools']) && \array_key_exists('listChanged', $data['tools'])) { + $toolsListChanged = (bool) $data['tools']['listChanged']; + } elseif (\is_object($data['tools']) && property_exists($data['tools'], 'listChanged')) { + $toolsListChanged = (bool) $data['tools']->listChanged; + } + } + + return new self( + tools: $toolsEnabled, + toolsListChanged: $toolsListChanged, + resources: $resourcesEnabled, + resourcesSubscribe: $resourcesSubscribe, + resourcesListChanged: $resourcesListChanged, + prompts: $promptsEnabled, + promptsListChanged: $promptsListChanged, + logging: $loggingEnabled, + completions: $completionsEnabled, + experimental: $data['experimental'] ?? null + ); + } + + /** + * @return array{ + * logging?: object, + * completions?: object, + * prompts?: object, + * resources?: object, + * tools?: object, + * experimental?: object, + * } + */ + public function jsonSerialize(): array + { + $data = []; + + if ($this->logging) { + $data['logging'] = new \stdClass(); + } + if ($this->completions) { + $data['completions'] = new \stdClass(); + } + + if ($this->prompts || $this->promptsListChanged) { + $data['prompts'] = new \stdClass(); + if ($this->promptsListChanged) { + $data['prompts']->listChanged = $this->promptsListChanged; + } + } + + if ($this->resources || $this->resourcesSubscribe || $this->resourcesListChanged) { + $data['resources'] = new \stdClass(); + if ($this->resourcesSubscribe) { + $data['resources']->subscribe = $this->resourcesSubscribe; + } + if ($this->resourcesListChanged) { + $data['resources']->listChanged = $this->resourcesListChanged; + } + } + + if ($this->tools || $this->toolsListChanged) { + $data['tools'] = new \stdClass(); + if ($this->toolsListChanged) { + $data['tools']->listChanged = $this->toolsListChanged; + } + } + + if ($this->experimental) { + $data['experimental'] = (object) $this->experimental; + } + + return $data; + } +} diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php new file mode 100644 index 0000000..29646ef --- /dev/null +++ b/src/Schema/Tool.php @@ -0,0 +1,105 @@ +, + * required: string[]|null + * } + * @phpstan-type ToolData array{ + * name: string, + * inputSchema: ToolInputSchema, + * description?: string|null, + * annotations?: ToolAnnotationsData, + * } + * + * @author Kyrian Obikwelu + */ +class Tool implements \JsonSerializable +{ + /** + * @param string $name the name of the tool + * @param string|null $description A human-readable description of the tool. + * This can be used by clients to improve the LLM's understanding of + * available tools. It can be thought of like a "hint" to the model. + * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool + * @param ToolAnnotations|null $annotations optional additional tool information + */ + public function __construct( + public readonly string $name, + public readonly array $inputSchema, + public readonly ?string $description, + public readonly ?ToolAnnotations $annotations, + ) { + if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { + throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); + } + } + + /** + * @param ToolData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in Tool data.'); + } + if (!isset($data['inputSchema']) || !\is_array($data['inputSchema'])) { + throw new InvalidArgumentException('Invalid or missing "inputSchema" in Tool data.'); + } + if (!isset($data['inputSchema']['type']) || 'object' !== $data['inputSchema']['type']) { + throw new InvalidArgumentException('Tool inputSchema must be of type "object".'); + } + if (isset($data['inputSchema']['properties']) && \is_array($data['inputSchema']['properties']) && empty($data['inputSchema']['properties'])) { + $data['inputSchema']['properties'] = new \stdClass(); + } + + return new self( + $data['name'], + $data['inputSchema'], + isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, + isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null + ); + } + + /** + * @return array{ + * name: string, + * inputSchema: ToolInputSchema, + * description?: string, + * annotations?: ToolAnnotations, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'name' => $this->name, + 'inputSchema' => $this->inputSchema, + ]; + if (null !== $this->description) { + $data['description'] = $this->description; + } + if (null !== $this->annotations) { + $data['annotations'] = $this->annotations; + } + + return $data; + } +} diff --git a/src/Schema/ToolAnnotations.php b/src/Schema/ToolAnnotations.php new file mode 100644 index 0000000..60e457f --- /dev/null +++ b/src/Schema/ToolAnnotations.php @@ -0,0 +1,84 @@ + + */ +class ToolAnnotations implements \JsonSerializable +{ + /** + * @param ?string $title a human-readable title for the tool + * @param ?bool $readOnlyHint if true, the tool does not modify its environment + * @param ?bool $destructiveHint If true, the tool may perform destructive updates to its environment. If false, the tool performs only additive updates. + * @param ?bool $idempotentHint If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment. (This property is meaningful only when `readOnlyHint == false`) + * @param ?bool $openWorldHint If true, this tool may interact with an "open world" of external entities. If false, the tool's domain of interaction is closed. For example, the world of a web search tool is open, whereas that of a memory tool is not. + */ + public function __construct( + public readonly ?string $title = null, + public readonly ?bool $readOnlyHint = null, + public readonly ?bool $destructiveHint = null, + public readonly ?bool $idempotentHint = null, + public readonly ?bool $openWorldHint = null, + ) { + } + + /** + * @param ToolAnnotationsData $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['title'] ?? null, + $data['readOnlyHint'] ?? null, + $data['destructiveHint'] ?? null, + $data['idempotentHint'] ?? null, + $data['openWorldHint'] ?? null + ); + } + + /** + * @return ToolAnnotationsData + */ + public function jsonSerialize(): array + { + $data = []; + if (null !== $this->title) { + $data['title'] = $this->title; + } + if (null !== $this->readOnlyHint) { + $data['readOnlyHint'] = $this->readOnlyHint; + } + if (null !== $this->destructiveHint) { + $data['destructiveHint'] = $this->destructiveHint; + } + if (null !== $this->idempotentHint) { + $data['idempotentHint'] = $this->idempotentHint; + } + if (null !== $this->openWorldHint) { + $data['openWorldHint'] = $this->openWorldHint; + } + + return $data; + } +} diff --git a/src/Server/Configuration.php b/src/Server/Configuration.php new file mode 100644 index 0000000..f5c4fab --- /dev/null +++ b/src/Server/Configuration.php @@ -0,0 +1,39 @@ + + */ +class Configuration +{ + /** + * @param Implementation $serverInfo info about this MCP server application + * @param ServerCapabilities $capabilities capabilities of this MCP server application + * @param int $paginationLimit maximum number of items to return for list methods + * @param string|null $instructions instructions describing how to use the server and its features + */ + public function __construct( + public readonly Implementation $serverInfo, + public readonly ServerCapabilities $capabilities, + public readonly int $paginationLimit = 50, + public readonly ?string $instructions = null, + ) { + } +}