feat: menu list, get, create and location 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:
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-assign-location: assigns a menu to a theme location (menu_id 0 removes
|
||||
* the assignment). Idempotent: assigning the same menu again, or removing an
|
||||
* already-empty location, changes nothing but still succeeds.
|
||||
*/
|
||||
final class MenuAssignLocationAbility implements Ability
|
||||
{
|
||||
public function __construct(private ChangeLog $log)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-assign-location';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menü-Position zuweisen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Weist einer Theme-Position (siehe lit-menu-list) ein Menü zu; menu_id 0 entfernt die Zuweisung. '
|
||||
. 'Das vorher zugewiesene Menü steht in previous_menu_id.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'location' => ['type' => 'string', 'minLength' => 1],
|
||||
'menu_id' => ['type' => 'integer', 'minimum' => 0],
|
||||
],
|
||||
'required' => ['location', 'menu_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema(['previous_menu_id' => ['type' => ['integer', 'null']]], ['previous_menu_id']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$location = (string) ($input['location'] ?? '');
|
||||
$menuId = (int) ($input['menu_id'] ?? 0);
|
||||
$registered = MenuLocations::registered();
|
||||
if (! array_key_exists($location, $registered)) {
|
||||
return Errors::invalidInput(
|
||||
'Unbekannte Menü-Position.',
|
||||
['location' => 'verfügbar: ' . implode(', ', array_keys($registered))]
|
||||
);
|
||||
}
|
||||
|
||||
if ($menuId > 0 && ! is_object(wp_get_nav_menu_object($menuId))) {
|
||||
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
|
||||
}
|
||||
|
||||
$previous = MenuLocations::assigned()[$location] ?? 0;
|
||||
MenuLocations::assign($location, $menuId);
|
||||
|
||||
$action = $menuId > 0 ? 'assigned' : 'unassigned';
|
||||
$summary = $menuId > 0
|
||||
? sprintf('Menü %d der Position %s zugewiesen', $menuId, $location)
|
||||
: sprintf('Zuweisung der Position %s entfernt', $location);
|
||||
|
||||
// The assignment is saved – a change log failure from here on is logged and reported as a warning only.
|
||||
$warnings = [];
|
||||
$logId = FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), 'menu_location', $location, $summary),
|
||||
$this->name() . ' change log',
|
||||
'Änderungsprotokoll konnte nicht geschrieben werden; die Zuweisung ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
return ToolResult::ok($summary . '.')
|
||||
->changed($location, 'menu_location', $action, $registered[$location])
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->warnings($warnings)
|
||||
->with('previous_menu_id', $previous > 0 ? $previous : null)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-create: creates an empty menu and optionally assigns it to a theme
|
||||
* location. Menu items are added afterwards via lit/menu-set-items (Task 14).
|
||||
*/
|
||||
final class MenuCreateAbility implements Ability
|
||||
{
|
||||
public function __construct(private ChangeLog $log)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-create';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menü anlegen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Legt ein leeres Menü an und weist es optional einer Theme-Position zu. Einträge danach mit '
|
||||
. 'lit-menu-set-items setzen.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string', 'minLength' => 1],
|
||||
'location' => ['type' => 'string', 'description' => 'Theme-Position aus lit-menu-list.'],
|
||||
],
|
||||
'required' => ['name'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema(['menu_id' => ['type' => 'integer']], ['menu_id']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
$location = isset($input['location']) ? (string) $input['location'] : '';
|
||||
if ($location !== '' && ! array_key_exists($location, MenuLocations::registered())) {
|
||||
return Errors::invalidInput(
|
||||
'Unbekannte Menü-Position.',
|
||||
['location' => 'verfügbar: ' . implode(', ', array_keys(MenuLocations::registered()))]
|
||||
);
|
||||
}
|
||||
|
||||
$menuId = wp_create_nav_menu($name);
|
||||
if ($menuId instanceof WP_Error) {
|
||||
return Errors::invalidInput(
|
||||
'Menü konnte nicht angelegt werden: ' . $menuId->get_error_message(),
|
||||
['name' => 'bereits vergeben oder ungültig'],
|
||||
'Vorhandene Menüs über lit-menu-list abrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
$menuId = (int) $menuId;
|
||||
if ($location !== '') {
|
||||
MenuLocations::assign($location, $menuId);
|
||||
}
|
||||
|
||||
// The menu is saved – a change log failure from here on is logged and reported as a warning only.
|
||||
$warnings = [];
|
||||
$summary = sprintf('Menü "%s" angelegt', $name)
|
||||
. ($location !== '' ? sprintf(' (Position %s)', $location) : '');
|
||||
$logId = FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary),
|
||||
$this->name() . ' change log',
|
||||
'Änderungsprotokoll konnte nicht geschrieben werden; das Menü ist angelegt.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
return ToolResult::ok($summary . sprintf(', ID %d.', $menuId))
|
||||
->changed((string) $menuId, 'nav_menu', 'created', $name)
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->warnings($warnings)
|
||||
->with('menu_id', $menuId)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-get: a menu as a tree (P4 ruling: same node shape lit/menu-set-items,
|
||||
* Task 14, accepts as input, so the result can be edited and sent back as-is).
|
||||
*/
|
||||
final class MenuGetAbility implements Ability
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-get';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menü lesen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Liefert ein Menü als Baum (id, title, type post/taxonomy/custom, object/object_id, url, '
|
||||
. 'children). object_id und url sind bei post/taxonomy-Einträgen nur informativ (WordPress leitet '
|
||||
. 'sie aus dem verlinkten Objekt ab); nur bei custom-Einträgen ist url maßgeblich. Der Baum kann '
|
||||
. 'bearbeitet an lit-menu-set-items zurückgegeben werden.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => ['menu_id' => ['type' => 'integer', 'minimum' => 1]],
|
||||
'required' => ['menu_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'locations' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'items' => ['type' => 'array', 'items' => MenuTree::nodeSchema()],
|
||||
],
|
||||
'required' => ['id', 'name', 'locations', 'items'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$menuId = (int) ($input['menu_id'] ?? 0);
|
||||
$menu = wp_get_nav_menu_object($menuId);
|
||||
if (! is_object($menu)) {
|
||||
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
|
||||
}
|
||||
|
||||
$items = wp_get_nav_menu_items($menuId);
|
||||
|
||||
return [
|
||||
'id' => (int) $menu->term_id,
|
||||
'name' => (string) $menu->name,
|
||||
'locations' => MenuLocations::of((int) $menu->term_id),
|
||||
'items' => MenuTree::build(is_array($items) ? $items : []),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-list: overview of all menus and the theme's menu locations, and which
|
||||
* menu (if any) currently sits at each location.
|
||||
*/
|
||||
final class MenuListAbility implements Ability
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-list';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menüs auflisten';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Listet alle Menüs mit Anzahl der Einträge sowie die Menü-Positionen des Themes und welches '
|
||||
. 'Menü dort zugewiesen ist.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return ['type' => 'object', 'properties' => [], 'additionalProperties' => false];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'menus' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'locations' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
],
|
||||
'required' => ['menus', 'locations'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$menus = [];
|
||||
foreach ((array) wp_get_nav_menus() as $menu) {
|
||||
$menus[] = [
|
||||
'id' => (int) $menu->term_id,
|
||||
'name' => (string) $menu->name,
|
||||
'slug' => (string) $menu->slug,
|
||||
'count' => (int) $menu->count,
|
||||
'locations' => MenuLocations::of((int) $menu->term_id),
|
||||
];
|
||||
}
|
||||
|
||||
$assigned = MenuLocations::assigned();
|
||||
$locations = [];
|
||||
foreach (MenuLocations::registered() as $location => $description) {
|
||||
$menuId = $assigned[$location] ?? 0;
|
||||
$locations[] = [
|
||||
'location' => (string) $location,
|
||||
'description' => $description,
|
||||
'menu_id' => $menuId > 0 ? $menuId : null,
|
||||
];
|
||||
}
|
||||
|
||||
return ['menus' => $menus, 'locations' => $locations];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
/**
|
||||
* Theme menu locations (theme mod nav_menu_locations).
|
||||
*/
|
||||
final class MenuLocations
|
||||
{
|
||||
/** @return array<string, string> Location slug => description. */
|
||||
public static function registered(): array
|
||||
{
|
||||
return array_map('strval', (array) get_registered_nav_menus());
|
||||
}
|
||||
|
||||
/** @return array<string, int> Location slug => menu ID. */
|
||||
public static function assigned(): array
|
||||
{
|
||||
$locations = get_nav_menu_locations();
|
||||
|
||||
return is_array($locations) ? array_map('intval', $locations) : [];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function of(int $menuId): array
|
||||
{
|
||||
return array_map('strval', array_keys(array_filter(
|
||||
self::assigned(),
|
||||
static fn (int $id): bool => $id === $menuId
|
||||
)));
|
||||
}
|
||||
|
||||
public static function assign(string $location, int $menuId): void
|
||||
{
|
||||
$locations = self::assigned();
|
||||
if ($menuId > 0) {
|
||||
$locations[$location] = $menuId;
|
||||
} else {
|
||||
unset($locations[$location]);
|
||||
}
|
||||
|
||||
set_theme_mod('nav_menu_locations', $locations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
/**
|
||||
* Turns the flat wp_get_nav_menu_items() list into a nested tree. The node shape
|
||||
* (node()) is deliberately the same shape lit/menu-set-items (Task 14) accepts as
|
||||
* input, minus "children": a lit/menu-get result can be edited and sent back as-is.
|
||||
* "url" is informational for object-linked items (post/taxonomy) – WordPress derives
|
||||
* it from object/object_id and ignores it on write; it is only authoritative for
|
||||
* "custom" items.
|
||||
*/
|
||||
final class MenuTree
|
||||
{
|
||||
/**
|
||||
* @param list<object> $items Sorted by menu_order (wp_get_nav_menu_items default).
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function build(array $items): array
|
||||
{
|
||||
$nodes = [];
|
||||
foreach ($items as $item) {
|
||||
$nodes[(int) $item->ID] = self::node($item);
|
||||
}
|
||||
|
||||
$children = [];
|
||||
foreach ($items as $item) {
|
||||
$id = (int) $item->ID;
|
||||
$parent = (int) $item->menu_item_parent;
|
||||
$children[$parent !== $id && isset($nodes[$parent]) ? $parent : 0][] = $id;
|
||||
}
|
||||
|
||||
return self::attach(0, $nodes, $children);
|
||||
}
|
||||
|
||||
/** @return array{id: int, title: string, type: string, object: string, object_id: int, url: string} */
|
||||
public static function node(object $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $item->ID,
|
||||
'title' => (string) $item->title,
|
||||
'type' => match ((string) $item->type) {
|
||||
'post_type' => 'post',
|
||||
'taxonomy' => 'taxonomy',
|
||||
default => 'custom',
|
||||
},
|
||||
'object' => (string) $item->object,
|
||||
'object_id' => (int) $item->object_id,
|
||||
'url' => (string) $item->url,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> JSON schema of one tree node (children not validated deeper). */
|
||||
public static function nodeSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer', 'description' => 'Menu-item ID (0 or missing for a new item).'],
|
||||
'title' => ['type' => 'string'],
|
||||
'type' => [
|
||||
'type' => 'string',
|
||||
'enum' => ['post', 'taxonomy', 'custom'],
|
||||
'description' => 'post/taxonomy link to object_id; custom uses url.',
|
||||
],
|
||||
'object' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Informational: WP object subtype (e.g. "page", "category", "custom").',
|
||||
],
|
||||
'object_id' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Informational for post/taxonomy items; ignored for custom items.',
|
||||
],
|
||||
'url' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Informational for post/taxonomy items (derived by WordPress); '
|
||||
. 'authoritative only for custom items.',
|
||||
],
|
||||
'children' => ['type' => 'array'],
|
||||
],
|
||||
'required' => ['id', 'title', 'type', 'children'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $nodes
|
||||
* @param array<int, list<int>> $children
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function attach(int $parent, array $nodes, array $children): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($children[$parent] ?? [] as $id) {
|
||||
$result[] = array_merge($nodes[$id], ['children' => self::attach($id, $nodes, $children)]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
@@ -66,6 +70,10 @@ final class WordPressIntegration implements Integration
|
||||
new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
|
||||
new RevisionsListAbility($this->schema),
|
||||
new RevisionRestoreAbility($this->schema, $this->log),
|
||||
new MenuListAbility(),
|
||||
new MenuGetAbility(),
|
||||
new MenuCreateAbility($this->log),
|
||||
new MenuAssignLocationAbility($this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user