feat: media preview as image content and download as embedded resource
lit/media-preview downscales an image attachment via wp_get_image_editor and returns it as ImageContent; lit/media-download returns a file (full or a named size) as an embedded resource, rejecting files above the upload limit. Both are direct tools (ruling Q6: embedded resources, like ImageContent, only become content blocks for tools called directly). Adds MediaFiles::isWithinUploads() so neither tool ever reads a file outside wp_upload_dir()'s basedir. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Media;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Renders a downscaled preview of an image attachment as an MCP ImageContent block
|
||||
* (spec §4.3). Direct tool only: the mcp-adapter only turns `{type: 'image', …}` into
|
||||
* an ImageContent block for tools called directly, never via mcp-adapter-execute-ability.
|
||||
* Never returns the original of a huge image: always downscales to at most max_size on
|
||||
* the longest edge via wp_get_image_editor.
|
||||
*/
|
||||
final class MediaPreviewAbility implements Ability
|
||||
{
|
||||
private const DEFAULT_SIZE = 512;
|
||||
private const EXTENSIONS = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
];
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/media-preview';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Bildvorschau';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Zeigt ein Bild der Mediathek als verkleinerte Vorschau (längste Kante max_size, Standard 512 px), '
|
||||
. 'damit der Inhalt beurteilt werden kann, z. B. für Alt-Texte.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'max_size' => ['type' => 'integer', 'minimum' => 64, 'maximum' => 1024],
|
||||
],
|
||||
'required' => ['id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
$attachment = MediaGuard::attachment($input['id'] ?? null);
|
||||
|
||||
return $attachment instanceof WP_Error ? $attachment : true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$attachment = MediaGuard::attachment($input['id'] ?? null);
|
||||
if ($attachment instanceof WP_Error) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
$id = (int) $attachment->ID;
|
||||
if (! wp_attachment_is_image($id)) {
|
||||
return Errors::unsupported(
|
||||
'Das Medium ist kein Bild.',
|
||||
'Metadaten über lit/media-get abrufen; Dateien über lit-media-download.'
|
||||
);
|
||||
}
|
||||
|
||||
$file = get_attached_file($id);
|
||||
if (! is_string($file) || ! is_readable($file) || ! MediaFiles::isWithinUploads($file)) {
|
||||
return Errors::notFound('Die Bilddatei fehlt auf dem Server.', 'Medium in der Mediathek prüfen.');
|
||||
}
|
||||
|
||||
AdminIncludes::load();
|
||||
$editor = wp_get_image_editor($file);
|
||||
if ($editor instanceof WP_Error) {
|
||||
return Errors::unsupported(
|
||||
'Das Bild kann nicht verarbeitet werden: ' . $editor->get_error_message(),
|
||||
'Metadaten über lit/media-get abrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
$max = max(64, min(1024, (int) ($input['max_size'] ?? self::DEFAULT_SIZE)));
|
||||
$size = (array) $editor->get_size();
|
||||
if (max((int) ($size['width'] ?? 0), (int) ($size['height'] ?? 0)) > $max) {
|
||||
$resized = $editor->resize($max, $max, false);
|
||||
if ($resized instanceof WP_Error) {
|
||||
return Errors::unsupported(
|
||||
'Vorschau konnte nicht verkleinert werden.',
|
||||
'Metadaten über lit/media-get abrufen.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$mime = isset(self::EXTENSIONS[$attachment->post_mime_type])
|
||||
? (string) $attachment->post_mime_type
|
||||
: 'image/jpeg';
|
||||
$target = wp_tempnam('lit-mcp-preview');
|
||||
$saved = $editor->save($target . '.' . self::EXTENSIONS[$mime], $mime);
|
||||
wp_delete_file($target);
|
||||
if (! is_array($saved) || ! isset($saved['path'])) {
|
||||
return Errors::unsupported(
|
||||
'Vorschau konnte nicht erzeugt werden.',
|
||||
'Metadaten über lit/media-get abrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
$bytes = file_get_contents((string) $saved['path']);
|
||||
wp_delete_file((string) $saved['path']);
|
||||
if ($bytes === false) {
|
||||
return Errors::internal();
|
||||
}
|
||||
|
||||
return ['type' => 'image', 'results' => $bytes, 'mimeType' => (string) ($saved['mime-type'] ?? $mime)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user