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:
@@ -18,7 +18,8 @@ des verbundenen WordPress-Benutzers.
|
||||
## Werkzeuge
|
||||
|
||||
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
|
||||
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`.
|
||||
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
|
||||
`lit-media-preview`, `lit-media-download`.
|
||||
|
||||
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und
|
||||
Hinweisen immer so geschrieben):
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Media;
|
||||
|
||||
/**
|
||||
* Media helpers (download_url, wp_tempnam, media_handle_sideload, …) live in
|
||||
* wp-admin/includes and are not loaded on REST requests.
|
||||
*/
|
||||
final class AdminIncludes
|
||||
{
|
||||
private const FILES = [
|
||||
'download_url' => 'wp-admin/includes/file.php',
|
||||
'media_handle_sideload' => 'wp-admin/includes/media.php',
|
||||
'wp_generate_attachment_metadata' => 'wp-admin/includes/image.php',
|
||||
];
|
||||
|
||||
public static function load(): void
|
||||
{
|
||||
foreach (self::FILES as $function => $file) {
|
||||
if (! function_exists($function) && is_readable(ABSPATH . $file)) {
|
||||
require_once ABSPATH . $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Media;
|
||||
|
||||
/**
|
||||
* Shared file-path safety check for lit-media-preview and lit-media-download
|
||||
* (controller ruling: never read a file outside the uploads directory, even
|
||||
* one get_attached_file() or an intermediate size's stored path points at).
|
||||
*/
|
||||
final class MediaFiles
|
||||
{
|
||||
public static function isWithinUploads(string $file): bool
|
||||
{
|
||||
$real = realpath($file);
|
||||
if ($real === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$uploads = wp_upload_dir();
|
||||
$basedir = is_array($uploads) ? realpath((string) ($uploads['basedir'] ?? '')) : false;
|
||||
|
||||
return is_string($basedir) && str_starts_with($real, $basedir . DIRECTORY_SEPARATOR);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ final class MediaIntegration implements Integration
|
||||
return [
|
||||
new MediaSearchAbility($presenter, $finder),
|
||||
new MediaGetAbility($presenter, $finder),
|
||||
new MediaPreviewAbility(),
|
||||
new MediaDownloadAbility($this->limits),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Media\MediaDownloadAbility;
|
||||
use LIT\MCP\Limits;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Post;
|
||||
|
||||
final class MediaDownloadAbilityTest extends TestCase
|
||||
{
|
||||
private string $file = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->file = (string) tempnam(sys_get_temp_dir(), 'lit-download');
|
||||
file_put_contents($this->file, 'PDFDATA');
|
||||
Functions\when('get_post')->justReturn(new WP_Post([
|
||||
'ID' => 40,
|
||||
'post_type' => 'attachment',
|
||||
'post_mime_type' => 'application/pdf',
|
||||
]));
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('get_attached_file')->justReturn($this->file);
|
||||
Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/preise.pdf');
|
||||
Functions\when('wp_basename')->alias('basename');
|
||||
Functions\when('wp_check_filetype')->justReturn(['ext' => 'pdf', 'type' => 'application/pdf']);
|
||||
Functions\when('wp_upload_dir')->justReturn(['basedir' => sys_get_temp_dir()]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (is_file($this->file)) {
|
||||
unlink($this->file);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testReturnsTheFileAsEmbeddedBlobResource(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn([]);
|
||||
|
||||
$result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]);
|
||||
|
||||
self::assertSame('resource', $result['type']);
|
||||
self::assertSame('https://kunde.de/wp-content/uploads/preise.pdf', $result['resource']['uri']);
|
||||
self::assertSame('application/pdf', $result['resource']['mimeType']);
|
||||
self::assertSame(base64_encode('PDFDATA'), $result['resource']['blob']);
|
||||
}
|
||||
|
||||
public function testFilesAboveTheLimitAreRefused(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn(['upload_mb' => 1]);
|
||||
file_put_contents($this->file, str_repeat('x', 1024 * 1024 + 1));
|
||||
|
||||
$result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]);
|
||||
|
||||
self::assertSame('lit_unsupported', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testUnknownSizeIsNotFound(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn([]);
|
||||
Functions\when('image_get_intermediate_size')->justReturn(false);
|
||||
|
||||
$result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40, 'size' => 'huge']);
|
||||
|
||||
self::assertSame('lit_not_found', $result->get_error_code());
|
||||
self::assertNull((new MediaDownloadAbility(new Limits()))->outputSchema());
|
||||
}
|
||||
|
||||
public function testFilesOutsideTheUploadsDirectoryAreRefused(): void
|
||||
{
|
||||
Functions\when('get_option')->justReturn([]);
|
||||
Functions\when('wp_upload_dir')->justReturn(['basedir' => sys_get_temp_dir() . '/lit-elsewhere']);
|
||||
|
||||
$result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]);
|
||||
|
||||
self::assertSame('lit_not_found', $result->get_error_code());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ final class MediaIntegrationTest extends TestCase
|
||||
private const EXPECTED = [
|
||||
'lit/media-search',
|
||||
'lit/media-get',
|
||||
'lit/media-preview',
|
||||
'lit/media-download',
|
||||
];
|
||||
|
||||
public function testProvidesTheMediaAbilities(): void
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Media\MediaPreviewAbility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
|
||||
final class MediaPreviewAbilityTest extends TestCase
|
||||
{
|
||||
private string $source = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->source = (string) tempnam(sys_get_temp_dir(), 'lit-src');
|
||||
file_put_contents($this->source, 'original');
|
||||
Functions\when('get_post')->justReturn(new WP_Post([
|
||||
'ID' => 33,
|
||||
'post_type' => 'attachment',
|
||||
'post_mime_type' => 'image/jpeg',
|
||||
]));
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('get_attached_file')->justReturn($this->source);
|
||||
Functions\when('wp_upload_dir')->justReturn(['basedir' => sys_get_temp_dir()]);
|
||||
Functions\when('wp_tempnam')->alias(static fn (): string => (string) tempnam(sys_get_temp_dir(), 'lit-prev'));
|
||||
Functions\when('wp_delete_file')->alias(static function (string $file): void {
|
||||
if (is_file($file)) {
|
||||
unlink($file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (is_file($this->source)) {
|
||||
unlink($this->source);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testReturnsADownscaledImageResult(): void
|
||||
{
|
||||
Functions\when('wp_attachment_is_image')->justReturn(true);
|
||||
$editor = Mockery::mock();
|
||||
$editor->allows('get_size')->andReturn(['width' => 2000, 'height' => 1000]);
|
||||
$editor->expects('resize')->with(256, 256, false)->andReturn(true);
|
||||
$editor->expects('save')->andReturnUsing(static function (string $path, string $mime): array {
|
||||
file_put_contents($path, 'JPEGDATA');
|
||||
return ['path' => $path, 'mime-type' => $mime];
|
||||
});
|
||||
Functions\when('wp_get_image_editor')->justReturn($editor);
|
||||
|
||||
$result = (new MediaPreviewAbility())->execute(['id' => 33, 'max_size' => 256]);
|
||||
|
||||
self::assertSame('image', $result['type']);
|
||||
self::assertSame('JPEGDATA', $result['results']);
|
||||
self::assertSame('image/jpeg', $result['mimeType']);
|
||||
}
|
||||
|
||||
public function testNonImagesAreUnsupported(): void
|
||||
{
|
||||
Functions\when('wp_attachment_is_image')->justReturn(false);
|
||||
|
||||
$result = (new MediaPreviewAbility())->execute(['id' => 33]);
|
||||
|
||||
self::assertSame('lit_unsupported', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testFilesOutsideTheUploadsDirectoryAreRefused(): void
|
||||
{
|
||||
Functions\when('wp_attachment_is_image')->justReturn(true);
|
||||
Functions\when('wp_upload_dir')->justReturn(['basedir' => sys_get_temp_dir() . '/lit-elsewhere']);
|
||||
|
||||
$result = (new MediaPreviewAbility())->execute(['id' => 33]);
|
||||
|
||||
self::assertSame('lit_not_found', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testIsADirectToolWithoutOutputSchema(): void
|
||||
{
|
||||
$ability = new MediaPreviewAbility();
|
||||
|
||||
self::assertNull($ability->outputSchema());
|
||||
self::assertTrue($ability->isDirect());
|
||||
self::assertTrue($ability->annotations()['readonly']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user