Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Laravel\Mcp\Exceptions\NotImplementedException;
use Laravel\Mcp\Server\Content\Blob;
use Laravel\Mcp\Server\Content\Notification;
use Laravel\Mcp\Server\Content\StructuredContent;
use Laravel\Mcp\Server\Content\Text;
use Laravel\Mcp\Server\Contracts\Content;

Expand Down Expand Up @@ -58,6 +59,16 @@ public static function blob(string $content): static
return new static(new Blob($content));
}

/**
* Return structured content. Note that multiple structured content responses will be merged into a single object.
*
* @param array<string, mixed>|object $content Must be an associative array or object.
*/
public static function structured(array|object $content): static
{
return new static(new StructuredContent($content));
}

public static function error(string $text): static
{
return new static(new Text($text), isError: true);
Expand Down
74 changes: 74 additions & 0 deletions src/Server/Content/StructuredContent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Content;

use Laravel\Mcp\Server\Contracts\Content;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;

class StructuredContent implements Content
{
/**
* @param array<string, mixed>|object $structuredContent
*/
public function __construct(protected array|object $structuredContent = [])
{
//
}

/**
* @return array<string, mixed>
*/
public function toTool(Tool $tool): array
{
return json_decode($this->toJsonString(), true);
}

/**
* @return array<string, mixed>
*/
public function toPrompt(Prompt $prompt): array
{
return $this->toArray();
}

/**
* @return array<string, mixed>
*/
public function toResource(Resource $resource): array
{
return [
'json' => $this->toJsonString(),
'uri' => $resource->uri(),
'name' => $resource->name(),
'title' => $resource->title(),
'mimeType' => $resource->mimeType() === 'text/plain'
? 'application/json'
: $resource->mimeType(),
];
}

public function __toString(): string
{
return $this->toJsonString();
}

/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'type' => 'text',
'text' => $this->toJsonString(),
];
}

private function toJsonString(): string
{
return json_encode($this->structuredContent, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
}
}
37 changes: 32 additions & 5 deletions src/Server/Methods/CallTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Content\StructuredContent;
use Laravel\Mcp\Server\Contracts\Errable;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
Expand Down Expand Up @@ -61,13 +62,39 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat
}

/**
* @return callable(Collection<int, Response>): array{content: array<int, array<string, mixed>>, isError: bool}
* @return callable(Collection<int, Response>): array{content: array<int, array<string, mixed>>, isError: bool, ?structuredContent: array<string, mixed>}
*/
protected function serializable(Tool $tool): callable
{
return fn (Collection $responses): array => [
'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(),
'isError' => $responses->contains(fn (Response $response): bool => $response->isError()),
];
return function (Collection $responses) use ($tool): array {
$groups = $responses->groupBy(fn (Response $response): string => $response->content() instanceof StructuredContent ? 'structuredContent' : 'content');

$content = $groups
->get('content')
?->map(fn (Response $response): array => $response->content()->toTool($tool));

$structuredContent = $groups
->get('structuredContent')
?->map(fn (Response $response): array => $response->content()->toTool($tool))
->collapse();

if ($structuredContent?->isNotEmpty()) {
return [
'content' => [
[
'type' => 'text',
'text' => $structuredContent->toJson(),
],
],
'isError' => $responses->contains(fn (Response $response): bool => $response->isError()),
'structuredContent' => $structuredContent->all(),
];
}

return [
'content' => $content?->all(),
'isError' => $responses->contains(fn (Response $response): bool => $response->isError()),
];
};
}
}
41 changes: 41 additions & 0 deletions tests/Fixtures/ReturnStructuredContentTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace Tests\Fixtures;

use Illuminate\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;

class ReturnStructuredContentTool extends Tool
{
protected string $description = 'This tool returns structured content';

public function handle(Request $request): array
{
$request->validate([
'name' => 'required|string',
'age' => 'required|integer',
]);

$name = $request->get('name');
$age = $request->get('age');

return [
Response::structured(['name' => $name]),
Response::structured(['age' => $age]),
];
}

public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()
->description('The name of the person to greet')
->required(),
'age' => $schema->integer()
->description('The age of the person')
->required(),
];
}
}
67 changes: 67 additions & 0 deletions tests/Unit/Content/StructuredContentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

use Laravel\Mcp\Server\Content\StructuredContent;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;

it('encodes content to resource payload with metadata', function (): void {
$structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]);
$resource = new class extends Resource
{
protected string $uri = 'file://readme.txt';

protected string $name = 'readme';

protected string $title = 'Readme File';

protected string $mimeType = 'application/json';
};

$payload = $structuredContent->toResource($resource);

expect($payload)->toEqual([
'json' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
'uri' => 'file://readme.txt',
'name' => 'readme',
'title' => 'Readme File',
'mimeType' => 'application/json',
]);
});

it('may be used in tools', function (): void {
$structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]);

$payload = $structuredContent->toTool(new class extends Tool {});

expect($payload)->toEqual([
'name' => 'John',
'age' => 30,
]);
});

it('may be used in prompts', function (): void {
$structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]);

$payload = $structuredContent->toPrompt(new class extends Prompt {});

expect($payload)->toEqual([
'type' => 'text',
'text' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
]);
});

it('casts to string as raw text', function (): void {
$structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]);

expect((string) $structuredContent)->toBe(json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
});

it('converts to array with type and text', function (): void {
$structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]);

expect($structuredContent->toArray())->toEqual([
'type' => 'text',
'text' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
]);
});
53 changes: 53 additions & 0 deletions tests/Unit/Resources/CallToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
use Tests\Fixtures\CurrentTimeTool;
use Tests\Fixtures\ReturnStructuredContentTool;
use Tests\Fixtures\SayHiTool;
use Tests\Fixtures\SayHiTwiceTool;

Expand Down Expand Up @@ -102,6 +103,58 @@
]);
});

it('returns a valid call tool response that merges structured content', function (): void {
$request = JsonRpcRequest::from([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'return-structured-content-tool',
'arguments' => ['name' => 'John Doe', 'age' => 30],
],
]);

$context = new ServerContext(
supportedProtocolVersions: ['2025-03-26'],
serverCapabilities: [],
serverName: 'Test Server',
serverVersion: '1.0.0',
instructions: 'Test instructions',
maxPaginationLength: 50,
defaultPaginationLength: 10,
tools: [ReturnStructuredContentTool::class],
resources: [],
prompts: [],
);

$method = new CallTool;

$this->instance('mcp.request', $request->toRequest());
$responses = $method->handle($request, $context);

[$response] = iterator_to_array($responses);

$payload = $response->toArray();

expect($payload['id'])->toEqual(1)
->and($payload['result'])->toEqual([
'content' => [
[
'type' => 'text',
'text' => json_encode([
'name' => 'John Doe',
'age' => 30,
]),
],
],
'isError' => false,
'structuredContent' => [
'name' => 'John Doe',
'age' => 30,
],
]);
});

it('returns a valid call tool response with validation error', function (): void {
$request = JsonRpcRequest::from([
'jsonrpc' => '2.0',
Expand Down
25 changes: 25 additions & 0 deletions tests/Unit/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Content\Blob;
use Laravel\Mcp\Server\Content\Notification;
use Laravel\Mcp\Server\Content\StructuredContent;
use Laravel\Mcp\Server\Content\Text;
use Laravel\Mcp\Server\Tool;

it('creates a notification response', function (): void {
$response = Response::notification('test.method', ['key' => 'value']);
Expand Down Expand Up @@ -122,3 +124,26 @@
$content = $response->content();
expect((string) $content)->toBe(json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
});

it('creates a response with structured content', function (): void {
$structuredData = ['type' => 'user_profile', 'data' => ['name' => 'John', 'age' => 30]];
$response = Response::structured($structuredData);

$genericTool = new class extends Tool
{
public function name(): string
{
return 'generic_tool';
}
};

expect($response->content())->toBeInstanceOf(StructuredContent::class);
expect($response->content()->toTool($genericTool))->toBe($structuredData);
expect($response->content()->toArray())->toBe([
'type' => 'text',
'text' => json_encode($structuredData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
]);
expect($response->isNotification())->toBeFalse();
expect($response->isError())->toBeFalse();
expect($response->role())->toBe(Role::USER);
});