diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 7e421c8..ea7ccf0 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -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): diff --git a/src/Integrations/Media/AdminIncludes.php b/src/Integrations/Media/AdminIncludes.php new file mode 100644 index 0000000..50ccdc6 --- /dev/null +++ b/src/Integrations/Media/AdminIncludes.php @@ -0,0 +1,27 @@ + '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; + } + } + } +} diff --git a/src/Integrations/Media/MediaDownloadAbility.php b/src/Integrations/Media/MediaDownloadAbility.php new file mode 100644 index 0000000..8033945 --- /dev/null +++ b/src/Integrations/Media/MediaDownloadAbility.php @@ -0,0 +1,133 @@ + '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)], + ]; + } +} diff --git a/src/Integrations/Media/MediaFiles.php b/src/Integrations/Media/MediaFiles.php new file mode 100644 index 0000000..ca5bdc1 --- /dev/null +++ b/src/Integrations/Media/MediaFiles.php @@ -0,0 +1,26 @@ +limits), ]; } } diff --git a/src/Integrations/Media/MediaPreviewAbility.php b/src/Integrations/Media/MediaPreviewAbility.php new file mode 100644 index 0000000..68ad717 --- /dev/null +++ b/src/Integrations/Media/MediaPreviewAbility.php @@ -0,0 +1,142 @@ + '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)]; + } +} diff --git a/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php b/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php new file mode 100644 index 0000000..1d97609 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php @@ -0,0 +1,85 @@ +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()); + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php index 76a8cd4..c635092 100644 --- a/tests/Unit/Integrations/Media/MediaIntegrationTest.php +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -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 diff --git a/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php b/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php new file mode 100644 index 0000000..40138f8 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php @@ -0,0 +1,92 @@ +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']); + } +}