Repository navigation
[5.x] Support categories, tags, and global sets in elements_* MCP tools #20469
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 5.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace craft\mcp\elements\adapters; | ||
|
|
||
| use Craft; | ||
| use craft\base\ElementInterface; | ||
| use craft\elements\Category; | ||
| use craft\elements\db\ElementQueryInterface; | ||
| use craft\elements\User; | ||
| use craft\mcp\ElementQueryCriteria; | ||
| use craft\mcp\elements\BaseElementAdapter; | ||
| use Mcp\Exception\ToolCallException; | ||
|
|
||
| /** | ||
| * @since 5.12.1 | ||
| */ | ||
| class CategoryAdapter extends BaseElementAdapter | ||
| { | ||
| public static function handle(): string | ||
| { | ||
| return 'categories'; | ||
| } | ||
|
|
||
| public static function elementType(): string | ||
| { | ||
| return Category::class; | ||
| } | ||
|
|
||
| protected function criteriaProperties(): array | ||
| { | ||
| return [ | ||
| 'slug' => [...ElementQueryCriteria::StringOrStringsSchema, 'description' => 'Category slug criteria.'], | ||
| 'group' => [...ElementQueryCriteria::StringOrStringsSchema, 'description' => 'Category group handle or handles.'], | ||
| 'groupId' => [...ElementQueryCriteria::IntegerOrIntegersSchema, 'description' => 'Category group ID or IDs.'], | ||
| ]; | ||
| } | ||
|
|
||
| protected function createAttributesSchema(): array | ||
| { | ||
| return [ | ||
| 'type' => 'object', | ||
| 'properties' => [ | ||
| 'groupId' => ['type' => 'integer', 'description' => 'Category group ID.'], | ||
| 'siteId' => ['type' => 'integer', 'description' => 'Site ID.'], | ||
| ...$this->sharedAttributeProperties(), | ||
| ], | ||
| 'required' => ['groupId'], | ||
| 'additionalProperties' => false, | ||
| ]; | ||
| } | ||
|
|
||
| protected function updateAttributesSchema(): array | ||
| { | ||
| return [ | ||
| 'type' => 'object', | ||
| 'properties' => $this->sharedAttributeProperties(), | ||
| 'additionalProperties' => false, | ||
| ]; | ||
| } | ||
|
|
||
| protected function fieldSchemaContextSchema(): array | ||
| { | ||
| return [ | ||
| 'type' => 'object', | ||
| 'description' => 'For a new category, provide groupId.', | ||
| 'properties' => [ | ||
| 'groupId' => ['type' => 'integer', 'description' => 'Category group ID of a new category.'], | ||
|
Copilot marked this conversation as resolved.
Resolved
|
||
| 'siteId' => ['type' => 'integer', 'description' => 'Site ID of a new category.'], | ||
| ], | ||
| 'additionalProperties' => false, | ||
| ]; | ||
| } | ||
|
|
||
| protected function duplicateModes(): array | ||
| { | ||
| return ['copy']; | ||
| } | ||
|
|
||
| public function fieldSchemaElement(?ElementInterface $element, array $context, User $actor): ElementInterface | ||
| { | ||
| if (($element === null) === !isset($context['groupId'])) { | ||
| throw new ToolCallException('Provide a category ID or UID, or provide context.groupId for a new category.'); | ||
| } | ||
|
|
||
| return parent::fieldSchemaElement($element, $context, $actor); | ||
| } | ||
|
|
||
| /** @param Category $element */ | ||
| public function canDelete(User $actor, ElementInterface $element): bool | ||
| { | ||
| return $this->canEditSite($actor, $element) && parent::canDelete($actor, $element); | ||
| } | ||
|
|
||
| public function duplicate(ElementInterface $element, ?string $mode): ElementInterface | ||
| { | ||
| if ($mode !== null && $mode !== 'copy') { | ||
| throw new ToolCallException('Categories only support the copy duplication mode.'); | ||
| } | ||
|
|
||
| return $this->lifecycle->duplicate($element); | ||
| } | ||
|
|
||
| /** @param Category $element */ | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in c3ac2a3: |
||
| protected function authorizeSave(User $actor, ElementInterface $element): void | ||
| { | ||
| if (!$this->canEditSite($actor, $element)) { | ||
| throw new ToolCallException('You are not authorized to edit categories for this site.'); | ||
| } | ||
|
|
||
| parent::authorizeSave($actor, $element); | ||
| } | ||
|
|
||
| protected function findQuery(): ElementQueryInterface | ||
| { | ||
| return Category::find()->drafts(null)->status(null); | ||
| } | ||
|
|
||
| /** | ||
| * Whether the actor can edit categories in the element’s site, as the control panel requires to save or delete them. | ||
| */ | ||
| private function canEditSite(User $actor, Category $category): bool | ||
| { | ||
| return !Craft::$app->getIsMultiSite() || $actor->can("editSite:{$category->getSite()->uid}"); | ||
| } | ||
|
|
||
| /** @return array<string, array<string, mixed>> */ | ||
| private function sharedAttributeProperties(): array | ||
| { | ||
| return [ | ||
| 'title' => ['type' => ['string', 'null'], 'description' => 'Category title.'], | ||
| 'slug' => ['type' => ['string', 'null'], 'description' => 'Category slug.'], | ||
| 'enabled' => ['type' => 'boolean', 'description' => 'Whether the category is enabled.'], | ||
| 'parentId' => ['type' => ['integer', 'null'], 'description' => 'Parent category ID.'], | ||
| ]; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace craft\mcp\elements\adapters; | ||
|
|
||
| use Craft; | ||
| use craft\base\ElementInterface; | ||
| use craft\elements\GlobalSet; | ||
| use craft\elements\User; | ||
| use craft\mcp\ElementQueryCriteria; | ||
| use craft\mcp\elements\BaseElementAdapter; | ||
| use Mcp\Exception\ToolCallException; | ||
|
|
||
| /** | ||
| * Global sets’ content can be read and updated. The sets themselves are configuration, created, renamed, and deleted | ||
| * in Settings → Globals. | ||
| * | ||
| * @since 5.12.1 | ||
| */ | ||
| class GlobalSetAdapter extends BaseElementAdapter | ||
| { | ||
| public static function handle(): string | ||
| { | ||
| return 'globalSets'; | ||
| } | ||
|
|
||
| public static function elementType(): string | ||
| { | ||
| return GlobalSet::class; | ||
| } | ||
|
|
||
| protected function criteriaProperties(): array | ||
| { | ||
| return [ | ||
| 'handle' => [...ElementQueryCriteria::StringOrStringsSchema, 'description' => 'Global set handle or handles.'], | ||
| ]; | ||
| } | ||
|
|
||
| protected function updateAttributesSchema(): array | ||
| { | ||
| return [ | ||
| 'type' => 'object', | ||
| 'description' => 'Global sets have no editable attributes. Update their custom fields with fields.', | ||
| 'maxProperties' => 0, | ||
| ]; | ||
| } | ||
|
|
||
| protected function notes(): array | ||
| { | ||
| return ['Only global set content can be updated. Global sets are created, renamed, and deleted in Settings → Globals, so they can’t be created, deleted, restored, or duplicated here.']; | ||
| } | ||
|
|
||
| public function fieldSchemaElement(?ElementInterface $element, array $context, User $actor): ElementInterface | ||
| { | ||
| if ($element === null) { | ||
| throw new ToolCallException('Provide a global set ID or UID.'); | ||
| } | ||
|
|
||
| return parent::fieldSchemaElement($element, $context, $actor); | ||
| } | ||
|
|
||
| /** @param GlobalSet $element */ | ||
| protected function authorizeSave(User $actor, ElementInterface $element): void | ||
| { | ||
| if (Craft::$app->getIsMultiSite() && !$actor->can("editSite:{$element->getSite()->uid}")) { | ||
| throw new ToolCallException('You are not authorized to edit global sets for this site.'); | ||
| } | ||
|
|
||
| parent::authorizeSave($actor, $element); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace craft\mcp\elements\adapters; | ||
|
|
||
| use craft\base\ElementInterface; | ||
| use craft\elements\db\ElementQueryInterface; | ||
| use craft\elements\Tag; | ||
| use craft\elements\User; | ||
| use craft\mcp\ElementQueryCriteria; | ||
| use craft\mcp\elements\BaseElementAdapter; | ||
| use Mcp\Exception\ToolCallException; | ||
|
|
||
| /** | ||
| * @since 5.12.1 | ||
| */ | ||
| class TagAdapter extends BaseElementAdapter | ||
| { | ||
| public static function handle(): string | ||
| { | ||
| return 'tags'; | ||
| } | ||
|
|
||
| public static function elementType(): string | ||
| { | ||
| return Tag::class; | ||
| } | ||
|
|
||
| protected function criteriaProperties(): array | ||
| { | ||
| return [ | ||
| 'group' => [...ElementQueryCriteria::StringOrStringsSchema, 'description' => 'Tag group handle or handles.'], | ||
| 'groupId' => [...ElementQueryCriteria::IntegerOrIntegersSchema, 'description' => 'Tag group ID or IDs.'], | ||
| ]; | ||
| } | ||
|
|
||
| protected function createAttributesSchema(): array | ||
| { | ||
| return [ | ||
| 'type' => 'object', | ||
| 'properties' => [ | ||
| 'groupId' => ['type' => 'integer', 'description' => 'Tag group ID.'], | ||
| 'title' => ['type' => 'string', 'minLength' => 1, 'description' => 'Tag title.'], | ||
| ], | ||
| 'required' => ['groupId', 'title'], | ||
| 'additionalProperties' => false, | ||
| ]; | ||
| } | ||
|
|
||
| protected function updateAttributesSchema(): array | ||
| { | ||
| return [ | ||
| 'type' => 'object', | ||
| 'properties' => [ | ||
| 'title' => ['type' => 'string', 'minLength' => 1, 'description' => 'Tag title.'], | ||
| ], | ||
| 'additionalProperties' => false, | ||
| ]; | ||
| } | ||
|
|
||
| protected function fieldSchemaContextSchema(): array | ||
| { | ||
| return [ | ||
| 'type' => 'object', | ||
| 'description' => 'For a new tag, provide groupId.', | ||
| 'properties' => [ | ||
| 'groupId' => ['type' => 'integer', 'description' => 'Tag group ID of a new tag.'], | ||
| ], | ||
| 'additionalProperties' => false, | ||
| ]; | ||
| } | ||
|
|
||
| public function fieldSchemaElement(?ElementInterface $element, array $context, User $actor): ElementInterface | ||
| { | ||
| if (($element === null) === !isset($context['groupId'])) { | ||
| throw new ToolCallException('Provide a tag ID or UID, or provide context.groupId for a new tag.'); | ||
| } | ||
|
|
||
| return parent::fieldSchemaElement($element, $context, $actor); | ||
| } | ||
|
|
||
| protected function findQuery(): ElementQueryInterface | ||
| { | ||
| return Tag::find()->status(null); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in c3ac2a3: categories now load with
drafts(null)->status(null), asEntryAdapterdoes, and tags withstatus(null). Listing defaults are unchanged. The test now disables the category and reads it back. It fails without this change ("Category not found.").