Introduce schema component

This commit is contained in:
Christopher Hertel 2025-08-01 16:41:16 +02:00
commit 6025f890f9
No known key found for this signature in database
GPG key ID: 111F6BFF7B6B5AA3
10 changed files with 5419 additions and 0 deletions

View file

@ -0,0 +1,117 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Annotations;
/**
* Represents audio content in MCP.
*
* @phpstan-import-type AnnotationsData from Annotations
*
* @phpstan-type AudioContentData = array{
* type: 'audio',
* data: string,
* mimeType: string,
* annotations?: AnnotationsData,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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;
}
}

View file

@ -0,0 +1,85 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
/**
* Represents blob resource contents in MCP.
*
* @phpstan-type BlobResourceContentsData array{
* uri: string,
* mimeType?: string|null,
* blob: string
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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(),
];
}
}

View file

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
/**
* Base class for all content types in MCP.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
abstract class Content implements \JsonSerializable
{
public function __construct(
public readonly string $type,
) {
}
}

View file

@ -0,0 +1,152 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Annotations;
/**
* Represents embedded resource content within a message.
*
* @phpstan-import-type AnnotationsData from Annotations
* @phpstan-import-type TextResourceContentsData from TextResourceContents
* @phpstan-import-type BlobResourceContentsData from BlobResourceContents
*
* @phpstan-type EmbeddedResourceData = array{
* type: 'resource',
* resource: TextResourceContentsData|BlobResourceContentsData,
* annotations?: AnnotationsData
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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']);
}
}

View file

@ -0,0 +1,93 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
/**
* @phpstan-type ImageContentData array{
* type: 'image',
* data: string,
* mimeType: string
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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,
];
}
}

View file

@ -0,0 +1,88 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Enum\Role;
/**
* Describes a message returned as part of a prompt.
*
* @phpstan-import-type TextContentData from TextContent
* @phpstan-import-type ImageContentData from ImageContent
* @phpstan-import-type AudioContentData from AudioContent
* @phpstan-import-type EmbeddedResourceData from EmbeddedResource
*
* @phpstan-type PromptMessageData array{
* role: string,
* content: TextContentData|ImageContentData|AudioContentData|EmbeddedResourceData,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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,
];
}
}

View file

@ -0,0 +1,48 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
/**
* The contents of a specific resource or sub-resource.
*
* @phpstan-type ResourceContentsData = array{
* uri: string,
* mimeType?: string|null
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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;
}
}

View file

@ -0,0 +1,72 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Enum\Role;
/**
* Describes a message issued to or received from an LLM API during sampling.
*
* @phpstan-type SamplingMessageData = array{
* role: string,
* content: TextContent|ImageContent|AudioContent
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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,
];
}
}

View file

@ -0,0 +1,96 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Annotations;
/**
* Represents text content in MCP.
*
* @phpstan-import-type AnnotationsData from Annotations
*
* @phpstan-type TextContentData array{
* type: 'text',
* text: string,
* annotations?: AnnotationsData,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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;
}
}

View file

@ -0,0 +1,67 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
/**
* Represents text resource contents in MCP.
*
* @phpstan-type TextResourceContentsData array{
* uri: string,
* mimeType?: string|null,
* text: string
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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(),
];
}
}