Skip to content

Commit e525a0f

Browse files
authored
Add Image and Audio content types with Storage integration (#155)
* Add Image and Audio content types with Storage integration * Replace imageFromStorage and audioFromStorage with unified fromStorage * Allow Image and Audio content in resources thorugh blob * Allow Image and Audio content in resources and restrict fromStorage to media types
1 parent d2fafc3 commit e525a0f

File tree

8 files changed

+528
-22
lines changed

8 files changed

+528
-22
lines changed

src/Response.php

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@
44

55
namespace Laravel\Mcp;
66

7+
use Illuminate\Support\Facades\Storage;
78
use Illuminate\Support\Traits\Conditionable;
89
use Illuminate\Support\Traits\Macroable;
910
use InvalidArgumentException;
1011
use JsonException;
1112
use Laravel\Mcp\Enums\Role;
12-
use Laravel\Mcp\Exceptions\NotImplementedException;
13+
use Laravel\Mcp\Server\Content\Audio;
1314
use Laravel\Mcp\Server\Content\Blob;
15+
use Laravel\Mcp\Server\Content\Image;
1416
use Laravel\Mcp\Server\Content\Notification;
1517
use Laravel\Mcp\Server\Content\Text;
1618
use Laravel\Mcp\Server\Contracts\Content;
19+
use League\Flysystem\UnableToReadFile;
1720

1821
class Response
1922
{
@@ -106,20 +109,40 @@ public function withMeta(array|string $meta, mixed $value = null): static
106109
return $this;
107110
}
108111

109-
/**
110-
* @throws NotImplementedException
111-
*/
112-
public static function audio(): Content
112+
public static function audio(string $data, string $mimeType = 'audio/wav'): static
113113
{
114-
throw NotImplementedException::forMethod(static::class, __METHOD__);
114+
return new static(new Audio($data, $mimeType));
115115
}
116116

117-
/**
118-
* @throws NotImplementedException
119-
*/
120-
public static function image(): Content
117+
public static function image(string $data, string $mimeType = 'image/png'): static
118+
{
119+
return new static(new Image($data, $mimeType));
120+
}
121+
122+
public static function fromStorage(string $path, ?string $disk = null, ?string $mimeType = null): static
121123
{
122-
throw NotImplementedException::forMethod(static::class, __METHOD__);
124+
/** @var \Illuminate\Filesystem\FilesystemAdapter $storage */
125+
$storage = Storage::disk($disk);
126+
127+
try {
128+
$data = $storage->get($path);
129+
} catch (UnableToReadFile $unableToReadFile) {
130+
throw new InvalidArgumentException("File not found at path [{$path}].", 0, $unableToReadFile);
131+
}
132+
133+
if ($data === null) {
134+
throw new InvalidArgumentException("File not found at path [{$path}].");
135+
}
136+
137+
$mimeType ??= $storage->mimeType($path) ?: throw new InvalidArgumentException(
138+
"Unable to determine MIME type for [{$path}].",
139+
);
140+
141+
return match (true) {
142+
str_starts_with($mimeType, 'image/') => static::image($data, $mimeType),
143+
str_starts_with($mimeType, 'audio/') => static::audio($data, $mimeType),
144+
default => throw new InvalidArgumentException("Unsupported MIME type [{$mimeType}] for [{$path}]."),
145+
};
123146
}
124147

125148
public function asAssistant(): static

src/Server/Content/Audio.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Content;
6+
7+
use Laravel\Mcp\Server\Concerns\HasMeta;
8+
use Laravel\Mcp\Server\Contracts\Content;
9+
use Laravel\Mcp\Server\Prompt;
10+
use Laravel\Mcp\Server\Resource;
11+
use Laravel\Mcp\Server\Tool;
12+
13+
class Audio implements Content
14+
{
15+
use HasMeta;
16+
17+
public function __construct(protected string $data, protected string $mimeType = 'audio/wav')
18+
{
19+
//
20+
}
21+
22+
/**
23+
* @return array<string, mixed>
24+
*/
25+
public function toTool(Tool $tool): array
26+
{
27+
return $this->toArray();
28+
}
29+
30+
/**
31+
* @return array<string, mixed>
32+
*/
33+
public function toPrompt(Prompt $prompt): array
34+
{
35+
return $this->toArray();
36+
}
37+
38+
/**
39+
* @return array<string, mixed>
40+
*/
41+
public function toResource(Resource $resource): array
42+
{
43+
return $this->mergeMeta([
44+
'blob' => base64_encode($this->data),
45+
'uri' => $resource->uri(),
46+
'mimeType' => $this->mimeType,
47+
]);
48+
}
49+
50+
public function __toString(): string
51+
{
52+
return $this->data;
53+
}
54+
55+
/**
56+
* @return array<string, mixed>
57+
*/
58+
public function toArray(): array
59+
{
60+
return $this->mergeMeta([
61+
'type' => 'audio',
62+
'data' => base64_encode($this->data),
63+
'mimeType' => $this->mimeType,
64+
]);
65+
}
66+
}

src/Server/Content/Image.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Content;
6+
7+
use Laravel\Mcp\Server\Concerns\HasMeta;
8+
use Laravel\Mcp\Server\Contracts\Content;
9+
use Laravel\Mcp\Server\Prompt;
10+
use Laravel\Mcp\Server\Resource;
11+
use Laravel\Mcp\Server\Tool;
12+
13+
class Image implements Content
14+
{
15+
use HasMeta;
16+
17+
public function __construct(protected string $data, protected string $mimeType = 'image/png')
18+
{
19+
//
20+
}
21+
22+
/**
23+
* @return array<string, mixed>
24+
*/
25+
public function toTool(Tool $tool): array
26+
{
27+
return $this->toArray();
28+
}
29+
30+
/**
31+
* @return array<string, mixed>
32+
*/
33+
public function toPrompt(Prompt $prompt): array
34+
{
35+
return $this->toArray();
36+
}
37+
38+
/**
39+
* @return array<string, mixed>
40+
*/
41+
public function toResource(Resource $resource): array
42+
{
43+
return $this->mergeMeta([
44+
'blob' => base64_encode($this->data),
45+
'uri' => $resource->uri(),
46+
'mimeType' => $this->mimeType,
47+
]);
48+
}
49+
50+
public function __toString(): string
51+
{
52+
return $this->data;
53+
}
54+
55+
/**
56+
* @return array<string, mixed>
57+
*/
58+
public function toArray(): array
59+
{
60+
return $this->mergeMeta([
61+
'type' => 'image',
62+
'data' => base64_encode($this->data),
63+
'mimeType' => $this->mimeType,
64+
]);
65+
}
66+
}

src/Server/Testing/TestResponse.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,11 +321,11 @@ protected function content(): array
321321
return (match (true) {
322322
// @phpstan-ignore-next-line
323323
$this->primitive instanceof Tool => collect($this->response->toArray()['result']['content'] ?? [])
324-
->map(fn (array $message): string => $message['text'] ?? ''),
324+
->map(fn (array $message): string => $message['text'] ?? $message['data'] ?? ''),
325325
// @phpstan-ignore-next-line
326326
$this->primitive instanceof Prompt => collect($this->response->toArray()['result']['messages'] ?? [])
327327
->map(fn (array $message): array => $message['content'])
328-
->map(fn (array $content): string => $content['text'] ?? ''),
328+
->map(fn (array $content): string => $content['text'] ?? $content['data'] ?? ''),
329329
// @phpstan-ignore-next-line
330330
$this->primitive instanceof Resource => collect($this->response->toArray()['result']['contents'] ?? [])
331331
->map(fn (array $item): string => $item['text'] ?? $item['blob'] ?? ''),

tests/Unit/Content/AudioTest.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Laravel\Mcp\Server\Content\Audio;
6+
use Laravel\Mcp\Server\Prompt;
7+
use Laravel\Mcp\Server\Resource;
8+
use Laravel\Mcp\Server\Tool;
9+
10+
it('may be used in tools', function (): void {
11+
$audio = new Audio('raw-audio-bytes', 'audio/mp3');
12+
13+
$payload = $audio->toTool(new class extends Tool {});
14+
15+
expect($payload)->toEqual([
16+
'type' => 'audio',
17+
'data' => base64_encode('raw-audio-bytes'),
18+
'mimeType' => 'audio/mp3',
19+
]);
20+
});
21+
22+
it('may be used in prompts', function (): void {
23+
$audio = new Audio('raw-audio-bytes', 'audio/mp3');
24+
25+
$payload = $audio->toPrompt(new class extends Prompt {});
26+
27+
expect($payload)->toEqual([
28+
'type' => 'audio',
29+
'data' => base64_encode('raw-audio-bytes'),
30+
'mimeType' => 'audio/mp3',
31+
]);
32+
});
33+
34+
it('may be used in resources', function (): void {
35+
$audio = new Audio('raw-audio-bytes', 'audio/mp3');
36+
37+
$resource = new class extends Resource
38+
{
39+
protected string $uri = 'file://audio/clip.mp3';
40+
41+
protected string $mimeType = 'audio/mp3';
42+
};
43+
44+
$payload = $audio->toResource($resource);
45+
46+
expect($payload)->toEqual([
47+
'blob' => base64_encode('raw-audio-bytes'),
48+
'uri' => 'file://audio/clip.mp3',
49+
'mimeType' => 'audio/mp3',
50+
]);
51+
});
52+
53+
it('casts to string as raw data', function (): void {
54+
$audio = new Audio('hello');
55+
56+
expect((string) $audio)->toBe('hello');
57+
});
58+
59+
it('converts to array with type, data and mimeType', function (): void {
60+
$audio = new Audio('bytes', 'audio/ogg');
61+
62+
expect($audio->toArray())->toEqual([
63+
'type' => 'audio',
64+
'data' => base64_encode('bytes'),
65+
'mimeType' => 'audio/ogg',
66+
]);
67+
});
68+
69+
it('defaults mimeType to audio/wav', function (): void {
70+
$audio = new Audio('data');
71+
72+
expect($audio->toArray())->toEqual([
73+
'type' => 'audio',
74+
'data' => base64_encode('data'),
75+
'mimeType' => 'audio/wav',
76+
]);
77+
});
78+
79+
it('supports meta via setMeta', function (): void {
80+
$audio = new Audio('binary-data');
81+
$audio->setMeta(['duration' => '3.5s']);
82+
83+
expect($audio->toArray())->toMatchArray([
84+
'type' => 'audio',
85+
'data' => base64_encode('binary-data'),
86+
'mimeType' => 'audio/wav',
87+
'_meta' => ['duration' => '3.5s'],
88+
]);
89+
});
90+
91+
it('does not include meta if null', function (): void {
92+
$audio = new Audio('data');
93+
94+
$array = $audio->toArray();
95+
96+
expect($array)->toMatchArray([
97+
'type' => 'audio',
98+
'data' => base64_encode('data'),
99+
'mimeType' => 'audio/wav',
100+
])->and($array)->not->toHaveKey('_meta');
101+
});

0 commit comments

Comments
 (0)