Skip to content
Merged
8 changes: 2 additions & 6 deletions src/main/php/io/modelcontextprotocol/McpServer.class.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php namespace io\modelcontextprotocol;

use io\modelcontextprotocol\server\{Delegate, JsonRpc, Response};
use io\modelcontextprotocol\server\{Delegate, JsonRpc, Response, Result};
use lang\FormatException;
use text\json\Json;
use util\NoSuchElementException;
Expand Down Expand Up @@ -51,11 +51,7 @@ public function __construct($delegate, string $version= '2025-06-18') {
},
'tools/call' => 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']);
},
Expand Down
145 changes: 145 additions & 0 deletions src/main/php/io/modelcontextprotocol/server/Result.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php namespace io\modelcontextprotocol\server;

/**
* Result
*
* @see https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-result
* @see https://modelcontextprotocol.io/specification/2025-11-25/server/tools#structured-content
* @see https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling
* @see https://modelcontextprotocol.io/specification/2025-11-25/server/tools#annotations
* @test io.modelcontextprotocol.unittest.ResultTest
*/
class Result {
private $struct;

/** Creates a new result with the given structure */
public function __construct(array $struct) { $this->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; }
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php namespace io\modelcontextprotocol\unittest;

use io\modelcontextprotocol\server\{Tool, Param, Value};
use io\modelcontextprotocol\server\{Tool, Param, Value, Result};
use lang\IllegalStateException;
use test\{Assert, Test};

Expand Down Expand Up @@ -79,6 +79,22 @@ public function fixture(
);
}

#[Test]
public function tool_with_result() {
$answer= $this->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() {
Expand Down
188 changes: 188 additions & 0 deletions src/test/php/io/modelcontextprotocol/unittest/ResultTest.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php namespace io\modelcontextprotocol\unittest;

use io\modelcontextprotocol\server\Result;
use test\{Assert, Test};

class ResultTest {
const OBJECT= ['temperature' => 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()
);
}
}
Loading