feat: content-query and content-get tools

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-25 22:39:43 +02:00
co-authored by Claude Opus 5.5
parent 95aae74466
commit d68e9b1f04
10 changed files with 806 additions and 4 deletions
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
final class ContentGetAbility implements Ability
{
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
{
}
public function name(): string
{
return 'lit/content-get';
}
public function label(): string
{
return 'Inhalt lesen';
}
public function description(): string
{
return 'Liest einen Eintrag vollständig: Kerndaten, Inhalt, Begriffe, alle Felder (Pods/Meta), '
. 'Beitragsbild, rev (für lit-content-update). Bei Divi-Inhalten wird content nicht ausgegeben; '
. 'hints verweist dann auf lit-divi-outline.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return PostPresenter::fullSchema();
}
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
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return $this->presenter->full($post);
}
}
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class ContentQueryAbility implements Ability
{
private const STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
{
}
public function name(): string
{
return 'lit/content-query';
}
public function label(): string
{
return 'Inhalte suchen';
}
public function description(): string
{
return 'Sucht Einträge eines Inhaltstyps. Filter: Status, Volltext, Taxonomie-Begriffe (IDs oder Slugs), '
. 'Feldwert, Datum, übergeordneter Eintrag; sortierbar; Cursor-Paginierung. Liefert Kurzdaten '
. '(ID, Titel, Status, URLs, Divi ja/nein) – Details über lit-content-get.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'post_type' => ['type' => 'string', 'description' => 'Inhaltstyp, z. B. page oder post.'],
'status' => [
'type' => 'array',
'items' => ['type' => 'string', 'enum' => self::STATUSES],
'description' => 'Standard: alle außer Papierkorb.',
],
'search' => ['type' => 'string'],
'taxonomy' => [
'type' => 'object',
'properties' => [
'taxonomy' => ['type' => 'string'],
'terms' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => ['integer', 'string']]],
],
'required' => ['taxonomy', 'terms'],
'additionalProperties' => false,
],
'field' => [
'type' => 'object',
'properties' => [
'key' => ['type' => 'string'],
'value' => ['type' => ['string', 'number', 'boolean']],
'compare' => ['type' => 'string', 'enum' => ['=', '!=', 'LIKE', 'EXISTS', 'NOT EXISTS']],
],
'required' => ['key'],
'additionalProperties' => false,
],
'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'parent' => ['type' => 'integer', 'minimum' => 0],
'orderby' => ['type' => 'string', 'enum' => ['date', 'modified', 'title', 'menu_order']],
'order' => ['type' => 'string', 'enum' => ['asc', 'desc']],
], Cursor::inputProperties()),
'required' => ['post_type'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'items' => ['type' => 'array', 'items' => PostPresenter::summarySchema()],
'next_cursor' => ['type' => ['string', 'null']],
],
'required' => ['items', 'next_cursor'],
];
}
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
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
return $type instanceof WP_Error ? $type : true;
}
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);
$posts = get_posts(array_merge($this->queryArgs($input), [
'posts_per_page' => $limit + 1,
'offset' => $offset,
]));
$page = Cursor::page(array_values($posts), $limit, $offset);
$items = [];
foreach ($page['items'] as $post) {
if ($post instanceof WP_Post && current_user_can('edit_post', (int) $post->ID)) {
$items[] = $this->presenter->summary($post);
}
}
return ['items' => $items, 'next_cursor' => $page['next_cursor']];
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
private function queryArgs(array $input): array
{
$args = [
'post_type' => (string) $input['post_type'],
'post_status' => $input['status'] ?? self::STATUSES,
'orderby' => (string) ($input['orderby'] ?? 'date'),
'order' => strtoupper((string) ($input['order'] ?? 'desc')),
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'suppress_filters' => false,
];
if (isset($input['search']) && $input['search'] !== '') {
$args['s'] = (string) $input['search'];
}
if (isset($input['parent'])) {
$args['post_parent'] = (int) $input['parent'];
}
if (isset($input['taxonomy']) && is_array($input['taxonomy'])) {
$args['tax_query'] = $this->taxQuery($input['taxonomy']);
}
if (isset($input['field']) && is_array($input['field'])) {
$compare = (string) ($input['field']['compare'] ?? '=');
$clause = ['key' => (string) $input['field']['key'], 'compare' => $compare];
if (! in_array($compare, ['EXISTS', 'NOT EXISTS'], true)) {
$clause['value'] = $input['field']['value'] ?? '';
}
$args['meta_query'] = [$clause];
}
$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> $filter
* @return array<int|string, mixed>
*/
private function taxQuery(array $filter): array
{
$taxonomy = (string) $filter['taxonomy'];
$terms = (array) $filter['terms'];
$ids = array_values(array_filter($terms, 'is_int'));
$slugs = array_values(array_filter($terms, 'is_string'));
$clauses = [];
if ($ids !== []) {
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $ids];
}
if ($slugs !== []) {
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'slug', 'terms' => $slugs];
}
return count($clauses) > 1 ? array_merge(['relation' => 'OR'], $clauses) : $clauses;
}
}
@@ -69,16 +69,30 @@ final class ContentTypesAbility implements Ability
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_posts')
? true
: Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
if (! current_user_can('edit_posts')) {
return Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
}
$postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : '';
return $this->forbiddenForType($postType) ?? true;
}
public function execute(array $input): mixed
{
$postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : '';
if ($postType === '') {
return ['post_types' => $this->schema->describePostTypes()];
$types = array_values(array_filter(
$this->schema->describePostTypes(),
fn (array $description): bool => $this->canEdit((string) $description['name'])
));
return ['post_types' => $types];
}
$forbidden = $this->forbiddenForType($postType);
if ($forbidden !== null) {
return $forbidden;
}
$description = $this->schema->describe($postType, true);
@@ -91,4 +105,27 @@ final class ContentTypesAbility implements Ability
return ['post_types' => [$description]];
}
/**
* Only post types the user can edit are listed or described (spec §4.5): requesting
* a managed but non-editable type is a forbidden error, not a silent omission.
*/
private function forbiddenForType(string $postType): ?WP_Error
{
if ($postType !== '' && $this->schema->isManaged($postType) && ! $this->canEdit($postType)) {
return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $postType));
}
return null;
}
private function canEdit(string $postType): bool
{
$object = get_post_type_object($postType);
$capability = is_object($object) && isset($object->cap->edit_posts)
? (string) $object->cap->edit_posts
: 'edit_posts';
return current_user_can($capability);
}
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Post;
/**
* Output format of an entry for the content tools. Not final so abilities can be
* unit-tested with a mock.
*/
class PostPresenter
{
public const DIVI_HINT = 'Dieser Inhalt ist mit Divi gebaut. Die Divi-Struktur wird über lit-divi-outline '
. 'gelesen und mit den lit-divi-*-Tools bearbeitet; fehlen diese Tools, kann der Divi-Inhalt hier nicht '
. 'bearbeitet werden. content wird deshalb nicht ausgegeben; Titel, Auszug, Felder und Begriffe sind '
. 'über lit-content-update änderbar.';
public function __construct(private ContentSchema $schema)
{
}
/** @return array<string, mixed> */
public function summary(WP_Post $post): array
{
return [
'id' => (int) $post->ID,
'type' => (string) $post->post_type,
'status' => (string) $post->post_status,
'title' => (string) $post->post_title,
'slug' => (string) $post->post_name,
'parent' => (int) $post->post_parent,
'date' => (string) $post->post_date,
'modified' => (string) $post->post_modified,
'url' => (string) get_permalink($post),
'edit_url' => (string) get_edit_post_link($post->ID, 'raw'),
'divi' => self::usesDivi($post),
];
}
/** @return array<string, mixed> */
public function full(WP_Post $post): array
{
$divi = self::usesDivi($post);
return array_merge($this->summary($post), [
'excerpt' => (string) $post->post_excerpt,
'content' => $divi ? null : (string) $post->post_content,
'menu_order' => (int) $post->menu_order,
'author' => [
'id' => (int) $post->post_author,
'name' => (string) get_the_author_meta('display_name', (int) $post->post_author),
],
'template' => (string) get_page_template_slug($post),
'preview_url' => (string) get_preview_post_link($post),
'terms' => JsonMap::of($this->terms($post)),
'fields' => JsonMap::of($this->fields($post)),
'featured_image' => $this->featuredImage($post),
'rev' => Rev::of((string) $post->post_content),
'hints' => $divi ? [self::DIVI_HINT] : [],
]);
}
public static function usesDivi(WP_Post $post): bool
{
return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on'
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
}
/** @return array<string, mixed> */
public static function summarySchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'type' => ['type' => 'string'],
'status' => ['type' => 'string'],
'title' => ['type' => 'string'],
'slug' => ['type' => 'string'],
'parent' => ['type' => 'integer'],
'date' => ['type' => 'string'],
'modified' => ['type' => 'string'],
'url' => ['type' => 'string'],
'edit_url' => ['type' => 'string'],
'divi' => ['type' => 'boolean'],
],
'required' => ['id', 'type', 'status', 'title', 'url', 'divi'],
];
}
/** @return array<string, mixed> */
public static function fullSchema(): array
{
$schema = self::summarySchema();
$schema['properties'] = array_merge($schema['properties'], [
'excerpt' => ['type' => 'string'],
'content' => ['type' => ['string', 'null']],
'menu_order' => ['type' => 'integer'],
'author' => ['type' => 'object'],
'template' => ['type' => 'string'],
'preview_url' => ['type' => 'string'],
'terms' => ['type' => 'object'],
'fields' => ['type' => 'object'],
'featured_image' => ['type' => ['object', 'null']],
'rev' => ['type' => 'string'],
'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
]);
$schema['required'] = array_merge($schema['required'], ['content', 'terms', 'fields', 'rev', 'hints']);
return $schema;
}
/** @return array<string, list<array{id: int, name: string, slug: string}>> */
private function terms(WP_Post $post): array
{
$result = [];
foreach ($this->schema->taxonomies((string) $post->post_type) as $taxonomy) {
$terms = wp_get_post_terms((int) $post->ID, $taxonomy['name']);
if (! is_array($terms)) {
continue;
}
$result[$taxonomy['name']] = array_map(
static fn (object $term): array => [
'id' => (int) $term->term_id,
'name' => (string) $term->name,
'slug' => (string) $term->slug,
],
$terms
);
}
return $result;
}
/** @return array<string, mixed> */
private function fields(WP_Post $post): array
{
$result = [];
foreach ($this->schema->fields((string) $post->post_type) as $key => $field) {
$result[$key] = $this->schema->sourceOf($field)->read((int) $post->ID, $field);
}
return $result;
}
/** @return array{id: int, url: string, alt: string}|null */
private function featuredImage(WP_Post $post): ?array
{
$id = (int) get_post_thumbnail_id($post);
if ($id === 0) {
return null;
}
return [
'id' => $id,
'url' => (string) wp_get_attachment_url($id),
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
];
}
}
@@ -6,7 +6,10 @@ namespace LIT\MCP\Integrations\WordPress;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
/**
@@ -31,8 +34,12 @@ final class WordPressIntegration implements Integration
public function abilities(): array
{
$presenter = new PostPresenter($this->schema);
return [
new ContentTypesAbility($this->schema),
new ContentQueryAbility($this->schema, $presenter),
new ContentGetAbility($this->schema, $presenter),
];
}
}