feat: media-search with missing_alt and unused filters

Ruling Q8 caps the unused scan at 20 candidates per call (not the
brief's 500/50), and each result (search and unused alike) is
filtered to attachments the current user may edit; overview.md lists
media-search as a direct tool (ruling Q2).

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 02:12:18 +02:00
co-authored by Claude Opus 5.5
parent b197d8171f
commit 5099c9fc89
5 changed files with 367 additions and 1 deletions
@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use WP_Error;
use WP_Post;
/**
* Media library search (spec §6.3). Direct tool. `unused` only ever checks a small,
* bounded number of candidates per call via MediaUsageFinder::isUsed() (each call does one
* full content scan – expensive): it stops after MAX_SCAN candidates and reports the
* interruption as a warning together with a next_cursor to continue.
*/
final class MediaSearchAbility implements Ability
{
/** Max. attachments checked with isUsed() per call – keeps the tool cheap (spec/ruling Q8). */
private const MAX_SCAN = 20;
private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';
public function __construct(private MediaPresenter $presenter, private MediaUsageFinder $finder)
{
}
public function name(): string
{
return 'lit/media-search';
}
public function label(): string
{
return 'Medien suchen';
}
public function description(): string
{
return 'Sucht in der Mediathek nach Text (Titel, Dateiname, Beschreibung), MIME-Typ (z. B. image oder '
. 'application/pdf) und Zeitraum. Nur Medien, die der Nutzer bearbeiten darf; Papierkorb ausgeschlossen. '
. 'missing_alt: Bilder ohne Alt-Text. unused: Medien ohne gefundene Verwendung – "keine Verwendung '
. 'gefunden" ist keine Garantie (Details je Medium über lit/media-get); als "möglicherweise verwendet" '
. 'erkannte Medien zählen bereits als verwendet und werden nicht gelistet. Prüft dafür je Aufruf '
. 'höchstens ' . self::MAX_SCAN . ' Medien; wird die Prüfung dadurch unterbrochen, folgt eine Warnung '
. 'und mit next_cursor kann fortgesetzt werden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'search' => ['type' => 'string', 'maxLength' => 200],
'mime' => ['type' => 'string', 'pattern' => '^[a-z]+(/[a-z0-9.+-]+)?$'],
'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'missing_alt' => ['type' => 'boolean'],
'unused' => ['type' => 'boolean'],
], Cursor::inputProperties()),
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'items' => ['type' => 'array', 'items' => MediaPresenter::summarySchema()],
'next_cursor' => ['type' => ['string', 'null']],
'warnings' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['items', 'next_cursor', 'warnings'],
];
}
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
{
return current_user_can('upload_files')
? true
: Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).');
}
public function execute(array $input): mixed
{
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$limit = Cursor::limit($input['limit'] ?? null);
$args = $this->queryArgs($input);
$page = ($input['unused'] ?? false) === true
? $this->unusedPage($args, $limit, $offset)
: $this->searchPage($args, $limit, $offset);
return [
'items' => array_map(
fn (WP_Post $attachment): array => $this->presenter->summary($attachment),
$page['items']
),
'next_cursor' => $page['next_cursor'],
'warnings' => $page['warnings'],
];
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
private function queryArgs(array $input): array
{
$args = [
'post_type' => 'attachment',
'post_status' => 'inherit',
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => true,
'suppress_filters' => false,
];
if (isset($input['search']) && $input['search'] !== '') {
$args['s'] = (string) $input['search'];
}
if (isset($input['mime']) && $input['mime'] !== '') {
$args['post_mime_type'] = (string) $input['mime'];
}
if (($input['missing_alt'] ?? false) === true) {
$args['post_mime_type'] ??= 'image';
$args['meta_query'] = [
'relation' => 'OR',
['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'],
['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='],
];
}
$date = [];
if (isset($input['date_after'])) {
$date['after'] = (string) $input['date_after'];
}
if (isset($input['date_before'])) {
$date['before'] = (string) $input['date_before'];
}
if ($date !== []) {
$args['date_query'] = [array_merge($date, ['inclusive' => true])];
}
return $args;
}
/**
* @param array<string, mixed> $args
* @return array{items: list<WP_Post>, next_cursor: ?string, warnings: list<string>}
*/
private function searchPage(array $args, int $limit, int $offset): array
{
$rows = get_posts(array_merge($args, ['posts_per_page' => $limit + 1, 'offset' => $offset]));
$page = Cursor::page(array_values($rows), $limit, $offset);
return [
'items' => array_values(array_filter($page['items'], [self::class, 'editable'])),
'next_cursor' => $page['next_cursor'],
'warnings' => [],
];
}
/**
* Fetches at most MAX_SCAN + 1 candidates (the extra one only tells us whether more exist,
* it is never passed to isUsed()) so at most MAX_SCAN expensive isUsed() checks happen here.
*
* @param array<string, mixed> $args
* @return array{items: list<WP_Post>, next_cursor: ?string, warnings: list<string>}
*/
private function unusedPage(array $args, int $limit, int $offset): array
{
$candidates = array_values(get_posts(array_merge($args, [
'posts_per_page' => self::MAX_SCAN + 1,
'offset' => $offset,
])));
$items = [];
$position = $offset;
$examined = 0;
foreach ($candidates as $attachment) {
if ($examined >= self::MAX_SCAN) {
break;
}
$position++;
$examined++;
if (self::editable($attachment) && ! $this->finder->isUsed((int) $attachment->ID)) {
$items[] = $attachment;
if (count($items) === $limit) {
return ['items' => $items, 'next_cursor' => Cursor::encode($position), 'warnings' => []];
}
}
}
if (count($candidates) <= self::MAX_SCAN) {
return ['items' => $items, 'next_cursor' => null, 'warnings' => []];
}
return [
'items' => $items,
'next_cursor' => Cursor::encode($position),
'warnings' => [sprintf(
'Suche nach unbenutzten Medien nach %d geprüften Medien unterbrochen; mit next_cursor fortsetzen.',
self::MAX_SCAN
)],
];
}
/** Only attachments the current user may edit are ever listed; trashed ones never reach here. */
private static function editable(WP_Post $attachment): bool
{
return current_user_can('edit_post', (int) $attachment->ID);
}
}