Skip to content

Commit b8ef8b7

Browse files
macbookandrewpushpak1300taylorotwell
authored
Add structuredContent & outputSchema Support (#83)
* Add structuredContent responses * fix tests * add Response::structured() * add another test * fix structure * Refactor * Fix Test * Add Tests * Add Tests * Add Tests * Refactor this * Fix CI * Update output schema handling to check for 'properties' key * Fix Test * Fix Test --------- Co-authored-by: Pushpak Chhajed <pushpak1300@gmail.com> Co-authored-by: Taylor Otwell <taylor@laravel.com>
1 parent e1acc1f commit b8ef8b7

16 files changed

+919
-7
lines changed

src/Response.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Illuminate\Support\Traits\Conditionable;
88
use Illuminate\Support\Traits\Macroable;
9+
use InvalidArgumentException;
910
use JsonException;
1011
use Laravel\Mcp\Enums\Role;
1112
use Laravel\Mcp\Exceptions\NotImplementedException;
@@ -58,6 +59,28 @@ public static function blob(string $content): static
5859
return new static(new Blob($content));
5960
}
6061

62+
/**
63+
* @param array<string, mixed> $response
64+
*
65+
* @throws JsonException
66+
*/
67+
public static function structured(array $response): ResponseFactory
68+
{
69+
if ($response === []) {
70+
throw new InvalidArgumentException('Structured content cannot be empty.');
71+
}
72+
73+
try {
74+
$json = json_encode($response, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
75+
} catch (JsonException $jsonException) {
76+
throw new InvalidArgumentException("Invalid structured content: {$jsonException->getMessage()}", 0, $jsonException);
77+
}
78+
79+
$content = Response::text($json);
80+
81+
return (new ResponseFactory($content))->withStructuredContent($response);
82+
}
83+
6184
public static function error(string $text): static
6285
{
6386
return new static(new Text($text), isError: true);

src/ResponseFactory.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
use Illuminate\Support\Traits\Macroable;
1111
use InvalidArgumentException;
1212
use Laravel\Mcp\Server\Concerns\HasMeta;
13+
use Laravel\Mcp\Server\Concerns\HasStructuredContent;
1314

1415
class ResponseFactory
1516
{
1617
use Conditionable;
1718
use HasMeta;
19+
use HasStructuredContent;
1820
use Macroable;
1921

2022
/**
@@ -50,6 +52,16 @@ public function withMeta(string|array $meta, mixed $value = null): static
5052
return $this;
5153
}
5254

55+
/**
56+
* @param array<string, mixed> $structuredContent
57+
*/
58+
public function withStructuredContent(array $structuredContent): static
59+
{
60+
$this->setStructuredContent($structuredContent);
61+
62+
return $this;
63+
}
64+
5365
/**
5466
* @return Collection<int, Response>
5567
*/
@@ -65,4 +77,12 @@ public function getMeta(): ?array
6577
{
6678
return $this->meta;
6779
}
80+
81+
/**
82+
* @return array<string, mixed>|null
83+
*/
84+
public function getStructuredContent(): ?array
85+
{
86+
return $this->structuredContent;
87+
}
6888
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Concerns;
6+
7+
trait HasStructuredContent
8+
{
9+
/**
10+
* @var array<string, mixed>|null
11+
*/
12+
protected ?array $structuredContent = null;
13+
14+
/**
15+
* @param array<string, mixed> $structuredContent
16+
*/
17+
public function setStructuredContent(array $structuredContent): void
18+
{
19+
$this->structuredContent ??= [];
20+
21+
$this->structuredContent = array_merge($this->structuredContent, $structuredContent);
22+
}
23+
24+
/**
25+
* @param array<string, mixed> $baseArray
26+
* @return array<string, mixed>
27+
*/
28+
public function mergeStructuredContent(array $baseArray): array
29+
{
30+
if ($this->structuredContent === null) {
31+
return $baseArray;
32+
}
33+
34+
return array_merge($baseArray, ['structuredContent' => $this->structuredContent]);
35+
}
36+
}

src/Server/Methods/CallTool.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,11 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat
6565
*/
6666
protected function serializable(Tool $tool): callable
6767
{
68-
return fn (ResponseFactory $factory): array => $factory->mergeMeta([
69-
'content' => $factory->responses()->map(fn (Response $response): array => $response->content()->toTool($tool))->all(),
70-
'isError' => $factory->responses()->contains(fn (Response $response): bool => $response->isError()),
71-
]);
68+
return fn (ResponseFactory $factory): array => $factory->mergeStructuredContent(
69+
$factory->mergeMeta([
70+
'content' => $factory->responses()->map(fn (Response $response): array => $response->content()->toTool($tool))->all(),
71+
'isError' => $factory->responses()->contains(fn (Response $response): bool => $response->isError()),
72+
])
73+
);
7274
}
7375
}

src/Server/Tool.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ public function schema(JsonSchema $schema): array
2020
return [];
2121
}
2222

23+
/**
24+
* Define the output schema for this tool's results.
25+
*
26+
* @return array<string, mixed>
27+
*/
28+
public function outputSchema(JsonSchema $schema): array
29+
{
30+
return [];
31+
}
32+
2333
/**
2434
* @return array<string, mixed>
2535
*/
@@ -36,6 +46,7 @@ public function toMethodCall(): array
3646
* title?: string|null,
3747
* description?: string|null,
3848
* inputSchema?: array<string, mixed>,
49+
* outputSchema?: array<string, mixed>,
3950
* annotations?: array<string, mixed>|object,
4051
* _meta?: array<string, mixed>
4152
* }
@@ -48,16 +59,26 @@ public function toArray(): array
4859
$this->schema(...),
4960
)->toArray();
5061

62+
$outputSchema = JsonSchema::object(
63+
$this->outputSchema(...),
64+
)->toArray();
65+
5166
$schema['properties'] ??= (object) [];
5267

53-
// @phpstan-ignore return.type
54-
return $this->mergeMeta([
68+
$result = [
5569
'name' => $this->name(),
5670
'title' => $this->title(),
5771
'description' => $this->description(),
5872
'inputSchema' => $schema,
5973
'annotations' => $annotations === [] ? (object) [] : $annotations,
60-
]);
74+
];
75+
76+
if (isset($outputSchema['properties'])) {
77+
$result['outputSchema'] = $outputSchema;
78+
}
79+
80+
// @phpstan-ignore return.type
81+
return $this->mergeMeta($result);
6182
}
6283

6384
/**
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Illuminate\JsonSchema\JsonSchema;
8+
use Laravel\Mcp\Request;
9+
use Laravel\Mcp\Response;
10+
use Laravel\Mcp\ResponseFactory;
11+
use Laravel\Mcp\Server\Tool;
12+
13+
class ResponseFactoryWithStructuredContentTool extends Tool
14+
{
15+
protected string $description = 'This tool returns a ResponseFactory with structured content';
16+
17+
public function handle(Request $request): ResponseFactory
18+
{
19+
return Response::make([
20+
Response::text('Processing complete with status: success'),
21+
])->withStructuredContent([
22+
'status' => 'success',
23+
'code' => 200,
24+
]);
25+
}
26+
27+
public function schema(JsonSchema $schema): array
28+
{
29+
return [];
30+
}
31+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Illuminate\JsonSchema\JsonSchema;
8+
use Laravel\Mcp\Request;
9+
use Laravel\Mcp\Response;
10+
use Laravel\Mcp\ResponseFactory;
11+
use Laravel\Mcp\Server\Tool;
12+
13+
class StructuredContentTool extends Tool
14+
{
15+
protected string $description = 'This tool returns structured content';
16+
17+
public function handle(Request $request): ResponseFactory
18+
{
19+
return Response::structured([
20+
'temperature' => 22.5,
21+
'conditions' => 'Partly cloudy',
22+
'humidity' => 65,
23+
]);
24+
}
25+
26+
public function schema(JsonSchema $schema): array
27+
{
28+
return [];
29+
}
30+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Illuminate\JsonSchema\JsonSchema;
8+
use Laravel\Mcp\Request;
9+
use Laravel\Mcp\Response;
10+
use Laravel\Mcp\ResponseFactory;
11+
use Laravel\Mcp\Server\Tool;
12+
13+
class StructuredContentWithMetaTool extends Tool
14+
{
15+
protected string $description = 'This tool returns structured content with meta';
16+
17+
public function handle(Request $request): ResponseFactory
18+
{
19+
return Response::structured([
20+
'result' => 'The operation completed successfully',
21+
])->withMeta(['requestId' => 'abc123']);
22+
}
23+
24+
public function schema(JsonSchema $schema): array
25+
{
26+
return [];
27+
}
28+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Illuminate\JsonSchema\JsonSchema;
8+
use Laravel\Mcp\Request;
9+
use Laravel\Mcp\Response;
10+
use Laravel\Mcp\ResponseFactory;
11+
use Laravel\Mcp\Server\Tool;
12+
13+
class ToolWithOutputSchema extends Tool
14+
{
15+
protected string $description = 'This tool returns user data with output schema';
16+
17+
public function handle(Request $request): ResponseFactory
18+
{
19+
return Response::structured([
20+
'id' => 123,
21+
'name' => 'John Doe',
22+
'email' => 'john@example.com',
23+
]);
24+
}
25+
26+
public function schema(JsonSchema $schema): array
27+
{
28+
return [];
29+
}
30+
31+
public function outputSchema(JsonSchema $schema): array
32+
{
33+
return [
34+
'id' => $schema->integer()->description('User ID')->required(),
35+
'name' => $schema->string()->description('User name')->required(),
36+
'email' => $schema->string()->description('User email'),
37+
];
38+
}
39+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Illuminate\JsonSchema\JsonSchema;
8+
use Laravel\Mcp\Request;
9+
use Laravel\Mcp\Response;
10+
use Laravel\Mcp\Server\Tool;
11+
12+
class ToolWithoutOutputSchema extends Tool
13+
{
14+
protected string $description = 'This tool does not define an output schema';
15+
16+
public function handle(Request $request): Response
17+
{
18+
return Response::text('Simple text response without schema');
19+
}
20+
21+
public function schema(JsonSchema $schema): array
22+
{
23+
return [];
24+
}
25+
}

0 commit comments

Comments
 (0)