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,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Media;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Limits;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Delivers a media file (original or a named image size) as an MCP embedded resource
|
||||
* (spec §4.3). Direct tool: like ImageContent (lit-media-preview), an embedded resource
|
||||
* is only turned into a content block by mcp-adapter for a tool called directly, never
|
||||
* via mcp-adapter-execute-ability (ruling Q6). Files above the upload limit (Limits) are
|
||||
* refused; the caller falls back to the URL.
|
||||
*/
|
||||
final class MediaDownloadAbility implements Ability
|
||||
{
|
||||
public function __construct(private Limits $limits)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/media-download';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Medium herunterladen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Liefert eine Datei der Mediathek (Original oder Bildgröße, siehe sizes in lit/media-get) als '
|
||||
. 'eingebettete Ressource. Dateien über dem Upload-Limit werden nicht übertragen; dann die URL nutzen.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'size' => ['type' => 'string', 'description' => 'full (Standard) oder Name einer Bildgröße.'],
|
||||
],
|
||||
'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;
|
||||
$size = (string) ($input['size'] ?? 'full');
|
||||
if ($size === 'full') {
|
||||
$path = get_attached_file($id);
|
||||
$url = (string) wp_get_attachment_url($id);
|
||||
} else {
|
||||
$data = image_get_intermediate_size($id, $size);
|
||||
if (! is_array($data) || ! isset($data['path'])) {
|
||||
return Errors::notFound(
|
||||
sprintf('Die Größe "%s" gibt es für dieses Medium nicht.', $size),
|
||||
'Verfügbare Größen stehen unter sizes in lit/media-get.'
|
||||
);
|
||||
}
|
||||
$uploads = wp_upload_dir();
|
||||
$path = (is_array($uploads) ? (string) ($uploads['basedir'] ?? '') : '') . '/' . $data['path'];
|
||||
$url = (string) ($data['url'] ?? '');
|
||||
}
|
||||
|
||||
if (! is_string($path) || ! is_readable($path) || ! MediaFiles::isWithinUploads($path)) {
|
||||
return Errors::notFound('Die Datei fehlt auf dem Server.', 'Medium in der Mediathek prüfen.');
|
||||
}
|
||||
|
||||
$bytesOnDisk = (int) filesize($path);
|
||||
if ($bytesOnDisk > $this->limits->uploadBytes()) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Die Datei ist zu groß für die Übertragung (%.1f MB, Limit %d MB).',
|
||||
$bytesOnDisk / 1048576,
|
||||
$this->limits->uploadMegabytes()
|
||||
),
|
||||
'Kleinere Bildgröße (size) wählen oder die URL aus lit/media-get verwenden.'
|
||||
);
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
$bytes = file_get_contents($path);
|
||||
if ($bytes === false) {
|
||||
return Errors::internal();
|
||||
}
|
||||
|
||||
$type = wp_check_filetype(wp_basename($path));
|
||||
$mime = is_string($type['type'] ?? null) && $type['type'] !== ''
|
||||
? $type['type']
|
||||
: (string) $attachment->post_mime_type;
|
||||
|
||||
return [
|
||||
'type' => 'resource',
|
||||
'resource' => ['uri' => $url, 'mimeType' => $mime, 'blob' => base64_encode($bytes)],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user