feat: media usage finder and media-get tool

Usage search distinguishes certain (ID referenced) and possible (file URL
only) usages, hides titles the user cannot read, and keeps queries bounded.
MediaGuard checks per-attachment capabilities and refuses trashed media
unless the caller opts in.

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:
2026-09-26 01:54:55 +02:00
co-authored by Claude Opus 5.5
parent 51e8846e1f
commit a2a2da1ae7
14 changed files with 1182 additions and 3 deletions
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use LIT\MCP\Abilities\JsonMap;
use WP_Post;
/**
* Output format of an attachment. Not final so abilities can be tested with a mock.
*/
class MediaPresenter
{
/** @return array<string, mixed> */
public function summary(WP_Post $attachment): array
{
$id = (int) $attachment->ID;
$meta = $this->metadata($attachment);
return [
'id' => $id,
'title' => (string) $attachment->post_title,
'filename' => wp_basename((string) get_attached_file($id)),
'mime_type' => (string) $attachment->post_mime_type,
'url' => (string) wp_get_attachment_url($id),
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
'width' => isset($meta['width']) ? (int) $meta['width'] : null,
'height' => isset($meta['height']) ? (int) $meta['height'] : null,
'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null,
'date' => (string) $attachment->post_date,
];
}
/**
* @param list<array<string, mixed>> $usages From MediaUsageFinder::usages().
* @return array<string, mixed>
*/
public function full(WP_Post $attachment, array $usages): array
{
$id = (int) $attachment->ID;
$sizes = [];
foreach ((array) ($this->metadata($attachment)['sizes'] ?? []) as $name => $size) {
$source = wp_get_attachment_image_src($id, (string) $name);
if (is_array($source)) {
$sizes[(string) $name] = [
'url' => (string) $source[0],
'width' => (int) $source[1],
'height' => (int) $source[2],
];
}
}
return array_merge($this->summary($attachment), [
'caption' => (string) $attachment->post_excerpt,
'description' => (string) $attachment->post_content,
'parent' => (int) $attachment->post_parent,
'sizes' => JsonMap::of($sizes),
'usages' => $usages,
'edit_url' => (string) get_edit_post_link($id, 'raw'),
]);
}
/** @return array<string, mixed> */
public static function summarySchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'filename' => ['type' => 'string'],
'mime_type' => ['type' => 'string'],
'url' => ['type' => 'string'],
'alt' => ['type' => 'string'],
'width' => ['type' => ['integer', 'null']],
'height' => ['type' => ['integer', 'null']],
'filesize' => ['type' => ['integer', 'null']],
'date' => ['type' => 'string'],
],
'required' => ['id', 'title', 'mime_type', 'url', 'alt'],
];
}
/** @return array<string, mixed> */
public static function fullSchema(): array
{
$schema = self::summarySchema();
$schema['properties'] = array_merge($schema['properties'], [
'caption' => ['type' => 'string'],
'description' => ['type' => 'string'],
'parent' => ['type' => 'integer'],
'sizes' => ['type' => 'object'],
'usages' => ['type' => 'array', 'items' => MediaUsageFinder::usageSchema()],
'edit_url' => ['type' => 'string'],
]);
$schema['required'] = array_merge($schema['required'], ['sizes', 'usages']);
return $schema;
}
/** @return array<string, mixed> */
private function metadata(WP_Post $attachment): array
{
$meta = wp_get_attachment_metadata((int) $attachment->ID);
return is_array($meta) ? $meta : [];
}
}