diff --git a/src/main/php/io/modelcontextprotocol/McpServer.class.php b/src/main/php/io/modelcontextprotocol/McpServer.class.php index 5c52b1e..76ba6e3 100755 --- a/src/main/php/io/modelcontextprotocol/McpServer.class.php +++ b/src/main/php/io/modelcontextprotocol/McpServer.class.php @@ -1,6 +1,6 @@ function($payload, $request) { if ($invokeable= $this->delegate->invokeable($payload['params']['name'])) { - $result= $invokeable((array)$payload['params']['arguments'], $request); - return ['content' => [['type' => 'text', 'text' => is_string($result) - ? $result - : Json::of($result) - ]]]; + return Result::cast($invokeable((array)$payload['params']['arguments'], $request))->struct(); } throw new NoSuchElementException($payload['params']['name']); }, diff --git a/src/main/php/io/modelcontextprotocol/server/Result.class.php b/src/main/php/io/modelcontextprotocol/server/Result.class.php new file mode 100755 index 0000000..44eb23a --- /dev/null +++ b/src/main/php/io/modelcontextprotocol/server/Result.class.php @@ -0,0 +1,145 @@ +struct= $struct; } + + /** Maps a value to result */ + private static function of($value) { + if (null === $value) { + return ['content' => []]; + } else if (is_scalar($value)) { + return ['content' => [['type' => 'text', 'text' => (string)$value]]]; + } else { + return ['structuredContent' => $value]; + } + } + + /** Creates a result from a given value */ + public static function cast($value): self { + return $value instanceof self ? $value : new self(self::of($value)); + } + + /** Creates a success result */ + public static function success($value= null): self { + return new self(self::of($value)); + } + + /** Creates an error result */ + public static function error($value= null): self { + return new self(self::of($value) + ['isError' => true]); + } + + /** + * Creates an special structured result including a textual representation, + * which defaults to a JSON-serialized version of the given object. + * + * @param var $object + * @param ?string|iterable $text + * @param ?bool $isError + */ + public static function structured($object, $text= null, $isError= null): self { + $self= new self(['content' => [], 'structuredContent' => $object]); + + if (null === $text) { + $self->text(json_encode($object)); + } else if (is_iterable($text)) { + foreach ($text as $part) { + $self->text($part); + } + } else { + $self->text($text); + } + + isset($isError) && $self->struct['isError']= (bool)$isError; + return $self; + } + + /** Adds a given typed content */ + public function add(string $type, array $struct, array $annotations= []): self { + $this->struct['content'][]= ['type' => $type] + $struct + ($annotations + ? ['annotations' => $annotations] + : [] + ); + return $this; + } + + /** + * Adds a text content + * + * @param string $string + * @param [:mixed] $annotations + */ + public function text($string, $annotations= []): self { + return $this->add('text', ['text' => (string)$string], $annotations); + } + + /** + * Adds an image content + * + * @param string|util.Bytes $data + * @param string $mime + * @param [:mixed] $annotations + */ + public function image($data, $mime, $annotations= []): self { + return $this->add('image', ['data' => base64_encode($data), 'mimeType' => $mime], $annotations); + } + + /** + * Adds an audio content + * + * @param string|util.Bytes $data + * @param string $mime + * @param [:mixed] $annotations + */ + public function audio($data, $mime, $annotations= []): self { + return $this->add('audio', ['data' => base64_encode($data), 'mimeType' => $mime], $annotations); + } + + /** + * Adds a resource link + * + * @param string $uri + * @param string $name + * @param string $description + * @param string $mime + * @param [:mixed] $annotations + */ + public function link($uri, $name, $description, $mime, $annotations= []): self { + return $this->add( + 'resource_link', + ['uri' => $uri, 'name' => $name, 'description' => $description, 'mimeType' => $mime], + $annotations + ); + } + + /** + * Adds an embedded resource + * + * @param string $uri + * @param string|util.Bytes $text + * @param string $mime + * @param [:mixed] $annotations + */ + public function resource($uri, $text, $mime, $annotations= []): self { + return $this->add( + 'resource', + ['resource' => ['uri' => $uri, 'text' => (string)$text, 'mimeType' => $mime]], + $annotations + ); + } + + /** Returns the structure */ + public function struct(): array { return $this->struct; } +} \ No newline at end of file diff --git a/src/test/php/io/modelcontextprotocol/unittest/McpServerToolCallingTest.class.php b/src/test/php/io/modelcontextprotocol/unittest/McpServerToolCallingTest.class.php index ac63d20..9692130 100755 --- a/src/test/php/io/modelcontextprotocol/unittest/McpServerToolCallingTest.class.php +++ b/src/test/php/io/modelcontextprotocol/unittest/McpServerToolCallingTest.class.php @@ -1,6 +1,6 @@ method('tools/call', ['name' => 'test_fixture', 'arguments' => []], new class() { + + #[Tool] + public function fixture() { + return Result::success()->text('Hi', ['audience' => ['user']]); + } + }); + + Assert::equals( + '{"jsonrpc":"2.0","id":"1","result":{"content":[{"type":"text","text":"Hi","annotations":{"audience":["user"]}}]}}', + $answer + ); + } + #[Test] public function tool_raising_error() { $answer= $this->method('tools/call', ['name' => 'test_fixture', 'arguments' => []], new class() { diff --git a/src/test/php/io/modelcontextprotocol/unittest/ResultTest.class.php b/src/test/php/io/modelcontextprotocol/unittest/ResultTest.class.php new file mode 100755 index 0000000..3aa50f6 --- /dev/null +++ b/src/test/php/io/modelcontextprotocol/unittest/ResultTest.class.php @@ -0,0 +1,188 @@ + 22.5, 'conditions' => 'Partly cloudy', 'humidity' => 65]; + + #[Test] + public function success() { + Assert::equals(['content' => []], Result::success()->struct()); + } + + #[Test] + public function success_with_text() { + Assert::equals( + ['content' => [['type' => 'text', 'text' => 'It worked']]], + Result::success('It worked')->struct() + ); + } + + #[Test] + public function success_with_object() { + Assert::equals( + ['structuredContent' => self::OBJECT], + Result::success(self::OBJECT)->struct() + ); + } + + #[Test] + public function error() { + Assert::equals(['content' => [], 'isError' => true], Result::error()->struct()); + } + + #[Test] + public function error_with_text() { + Assert::equals( + ['content' => [['type' => 'text', 'text' => 'Error 404']], 'isError' => true], + Result::error('Error 404')->struct() + ); + } + + #[Test] + public function error_with_object() { + Assert::equals( + ['structuredContent' => self::OBJECT, 'isError' => true], + Result::error(self::OBJECT)->struct() + ); + } + + #[Test] + public function add() { + Assert::equals( + ['content' => [['type' => 'text', 'text' => 'Test']]], + Result::success()->add('text', ['text' => 'Test'])->struct() + ); + } + + #[Test] + public function annotations() { + Assert::equals( + ['content' => [['type' => 'text', 'text' => 'Test','annotations' => ['audience' => ['user']]]]], + Result::success()->add('text', ['text' => 'Test'], ['audience' => ['user']])->struct() + ); + } + + #[Test] + public function with_text() { + Assert::equals( + ['content' => [['type' => 'text', 'text' => 'Tool result text']]], + Result::success()->text('Tool result text')->struct() + ); + } + + #[Test] + public function with_image() { + Assert::equals( + ['content' => [['type' => 'image', 'data' => 'R0lGODlhLi4u', 'mimeType' => 'image/gif']]], + Result::success()->image('GIF89a...', 'image/gif')->struct() + ); + } + + #[Test] + public function with_audio() { + Assert::equals( + ['content' => [['type' => 'audio', 'data' => 'UklGRi4uLg==', 'mimeType' => 'audio/wav']]], + Result::success()->audio('RIFF...', 'audio/wav')->struct() + ); + } + + #[Test] + public function with_resource_link() { + Assert::equals( + ['content' => [[ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + 'description' => 'Main', + 'mimeType' => 'text/x-rust', + ]]], + Result::success()->link('file:///project/src/main.rs', 'main.rs', 'Main', 'text/x-rust')->struct() + ); + } + + #[Test] + public function with_embedded_resource() { + $code= "fn main() {\n println!(\"Hello world!\");\n}"; + Assert::equals( + ['content' => [[ + 'type' => 'resource', + 'resource' => [ + 'uri' => 'file:///project/src/main.rs', + 'text' => $code, + 'mimeType' => 'text/x-rust', + ], + ]]], + Result::success()->resource('file:///project/src/main.rs', $code, 'text/x-rust')->struct() + ); + } + + #[Test] + public function structured() { + Assert::equals( + [ + 'structuredContent' => self::OBJECT, + 'content' => [['type' => 'text', 'text' => json_encode(self::OBJECT)]], + ], + Result::structured(self::OBJECT)->struct() + ); + } + + #[Test] + public function structured_with_text() { + $text= 'Temperature: 22.5°, partly cloudy with a humidity of 65'; + Assert::equals( + [ + 'structuredContent' => self::OBJECT, + 'content' => [['type' => 'text', 'text' => $text]], + ], + Result::structured(self::OBJECT, $text)->struct() + ); + } + + #[Test] + public function structured_with_iterable() { + $text= ['Temperature: 22.5°', 'Conditions: partly cloudy', 'Humidity: 65']; + Assert::equals( + [ + 'content' => [ + ['type' => 'text', 'text' => $text[0]], + ['type' => 'text', 'text' => $text[1]], + ['type' => 'text', 'text' => $text[2]], + ], + 'structuredContent' => self::OBJECT, + ], + Result::structured(self::OBJECT, $text)->struct() + ); + } + + #[Test] + public function structured_with_error() { + $error= ['code' => 'INVALID_DEPARTURE_DATE', 'message' => 'Departure date must be in the future']; + Assert::equals( + [ + 'structuredContent' => ['error' => $error], + 'content' => [['type' => 'text', 'text' => $error['message']]], + 'isError' => true, + ], + Result::structured(['error' => $error], $error['message'], true)->struct() + ); + } + + #[Test] + public function cast_scalar() { + Assert::equals( + ['content' => [['type' => 'text', 'text' => 'Test']]], + Result::success()->cast('Test')->struct() + ); + } + + #[Test] + public function cast_object() { + Assert::equals( + ['structuredContent' => self::OBJECT], + Result::success()->cast(self::OBJECT)->struct() + ); + } +} \ No newline at end of file