From 41786f628d5cc0325461584b24a247ab317b88f8 Mon Sep 17 00:00:00 2001 From: Brandon Kelly Date: Sat, 7 Nov 2026 22:26:19 +0000 Subject: [PATCH 1/2] Support categories, tags, and global sets in elements_* MCP tools Adds element adapters for categories (read, create, update, delete, duplicate), tags (read, create, update, delete), and global sets (read and update content only). Global sets' canSave() allows everyone, so MCP authorization now requires editGlobalSet for anything but viewing them, as the globals controller does, and never allows restoring deleted sets, which come back through project config. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8 --- CHANGELOG.md | 2 + src/mcp/Authorization.php | 7 ++ src/mcp/elements/ElementAdapterRegistry.php | 13 +- src/mcp/elements/adapters/CategoryAdapter.php | 117 ++++++++++++++++++ .../elements/adapters/GlobalSetAdapter.php | 72 +++++++++++ src/mcp/elements/adapters/TagAdapter.php | 81 ++++++++++++ tests/unit/mcp/ElementAdaptersTest.php | 97 +++++++++++++++ 7 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 src/mcp/elements/adapters/CategoryAdapter.php create mode 100644 src/mcp/elements/adapters/GlobalSetAdapter.php create mode 100644 src/mcp/elements/adapters/TagAdapter.php create mode 100644 tests/unit/mcp/ElementAdaptersTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ba20c85db..80b3ffa6363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - Renamed Craft’s MCP tools to use snake_case names, such as `elements_list` instead of `elements.list` and `craft_query` instead of `craft-query`, because VS Code, claude.ai, and other MCP clients reject or rename tools with dots in their names. Public MCP server approvals are updated automatically. Plugins should name their MCP tools the same way, such as `my_plugin_orders_list`. ([#20351](https://github.com/craftcms/cms/issues/20351)) - The `assets_create` and `assets_replace` MCP tools now accept base64-encoded file data, local file paths (stdio server only, limited to project directories where a local volume could be located), and files sent to single-use upload URLs from the new `assets_upload_url` MCP tool, in addition to download URLs. Download URLs no longer require a `file_id`, follow up to 3 redirects, and are validated against SSRF via craftcms/url-validator. - The `assets_create` MCP tool can now upload files to an Assets field’s default upload location, via its new `fieldId` and `elementId` arguments. +- The `elements_*` MCP tools now support categories, tags, and global sets. Global sets’ content can be read and updated by users with permission to edit them. +- Added `craft\mcp\elements\adapters\CategoryAdapter`, `GlobalSetAdapter`, and `TagAdapter`. - The `configuration_get`, `configuration_list`, `configuration_create`, and `configuration_update` MCP tools now return each record’s revision, and `configuration_update` and `configuration_delete` accept it as `expectedRevision`, failing without changing anything if the record has changed since it was read. Without `expectedRevision`, they can still overwrite changes made since the read. ([#20440](https://github.com/craftcms/cms/pull/20440)) - Added `craft\services\ProjectConfig::acquireLock()`. ([#20440](https://github.com/craftcms/cms/pull/20440)) - Added the `user_permissions_user_update` and `user_permissions_group_update` MCP tools, which add and remove specific permissions without resending complete lists. ([#20434](https://github.com/craftcms/cms/pull/20434)) diff --git a/src/mcp/Authorization.php b/src/mcp/Authorization.php index 06b569c2844..1a98327e68a 100644 --- a/src/mcp/Authorization.php +++ b/src/mcp/Authorization.php @@ -8,6 +8,7 @@ use craft\base\ElementInterface; use craft\elements\Asset; use craft\elements\Entry; +use craft\elements\GlobalSet; use craft\elements\User; use craft\models\VolumeFolder; @@ -28,6 +29,12 @@ public static function allows(User $user, string $ability, mixed $subject): bool || $user->can("changeAuthorForPeerEntries:{$subject->getSection()->uid}"); } + // Global sets’ own rules allow everyone to save them; the control panel requires permission to edit + // each set, and deleted sets can only come back through project config + if ($subject instanceof GlobalSet && $ability !== 'view' && ($subject->trashed || !$user->can("editGlobalSet:$subject->uid"))) { + return false; + } + $method = 'can' . ucfirst($ability); if (method_exists($elements, $method)) { diff --git a/src/mcp/elements/ElementAdapterRegistry.php b/src/mcp/elements/ElementAdapterRegistry.php index 08cf443f3a8..67b31114920 100644 --- a/src/mcp/elements/ElementAdapterRegistry.php +++ b/src/mcp/elements/ElementAdapterRegistry.php @@ -8,7 +8,10 @@ use craft\events\RegisterComponentTypesEvent; use craft\mcp\elements\adapters\AddressAdapter; use craft\mcp\elements\adapters\AssetAdapter; +use craft\mcp\elements\adapters\CategoryAdapter; use craft\mcp\elements\adapters\EntryAdapter; +use craft\mcp\elements\adapters\GlobalSetAdapter; +use craft\mcp\elements\adapters\TagAdapter; use craft\mcp\elements\adapters\UserAdapter; use yii\base\Component; use yii\base\InvalidArgumentException; @@ -25,7 +28,15 @@ public function types(): array { if ($this->_types === null) { $event = new RegisterComponentTypesEvent([ - 'types' => [EntryAdapter::class, AssetAdapter::class, UserAdapter::class, AddressAdapter::class], + 'types' => [ + EntryAdapter::class, + AssetAdapter::class, + UserAdapter::class, + AddressAdapter::class, + CategoryAdapter::class, + TagAdapter::class, + GlobalSetAdapter::class, + ], ]); $this->trigger(self::EVENT_REGISTER_ADAPTERS, $event); $this->_types = []; diff --git a/src/mcp/elements/adapters/CategoryAdapter.php b/src/mcp/elements/adapters/CategoryAdapter.php new file mode 100644 index 00000000000..0a41ffeef0b --- /dev/null +++ b/src/mcp/elements/adapters/CategoryAdapter.php @@ -0,0 +1,117 @@ + [...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.'], + ], + '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); + } + + 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 */ + 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 categories for this site.'); + } + + parent::authorizeSave($actor, $element); + } + + /** @return array> */ + 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.'], + ]; + } +} diff --git a/src/mcp/elements/adapters/GlobalSetAdapter.php b/src/mcp/elements/adapters/GlobalSetAdapter.php new file mode 100644 index 00000000000..3c46081c422 --- /dev/null +++ b/src/mcp/elements/adapters/GlobalSetAdapter.php @@ -0,0 +1,72 @@ + [...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); + } +} diff --git a/src/mcp/elements/adapters/TagAdapter.php b/src/mcp/elements/adapters/TagAdapter.php new file mode 100644 index 00000000000..5eedebe2889 --- /dev/null +++ b/src/mcp/elements/adapters/TagAdapter.php @@ -0,0 +1,81 @@ + [...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); + } +} diff --git a/tests/unit/mcp/ElementAdaptersTest.php b/tests/unit/mcp/ElementAdaptersTest.php new file mode 100644 index 00000000000..49ac3bc513a --- /dev/null +++ b/tests/unit/mcp/ElementAdaptersTest.php @@ -0,0 +1,97 @@ + ['class' => GlobalSetFixture::class]]; + } + + protected function _before(): void + { + Craft::$app->getUser()->setIdentity(User::find()->admin()->one()); + } + + protected function _after(): void + { + $this->categoryGroup && Craft::$app->getCategories()->deleteGroup($this->categoryGroup); + $this->tagGroup && Craft::$app->getTags()->deleteTagGroup($this->tagGroup); + Craft::$app->getUser()->setIdentity(null); + } + + public function testCategoriesAndTagsCanBeCreatedListedAndRead(): void + { + $this->categoryGroup = new CategoryGroup(['name' => 'MCP Topics', 'handle' => 'mcpTopics']); + $siteId = Craft::$app->getSites()->getPrimarySite()->id; + $this->categoryGroup->setSiteSettings([$siteId => new CategoryGroup_SiteSettings(['siteId' => $siteId, 'hasUrls' => false])]); + self::assertTrue(Craft::$app->getCategories()->saveGroup($this->categoryGroup)); + $this->tagGroup = new TagGroup(['name' => 'MCP Tags', 'handle' => 'mcpTags']); + self::assertTrue(Craft::$app->getTags()->saveTagGroup($this->tagGroup)); + + $category = $this->elements()->create('craft\elements\Category', ['groupId' => $this->categoryGroup->id, 'title' => 'Releases'])['element']; + $this->elements()->create('tags', ['groupId' => $this->tagGroup->id, 'title' => 'craft6']); + + $categories = $this->elements()->list('categories', ['group' => 'mcpTopics'])->structuredContent['elements']; + self::assertSame([$category['id']], array_column($categories, 'id')); + self::assertSame('Releases', $this->elements()->get('categories', $category['id'])['element']['title']); + self::assertSame(['craft6'], array_column($this->elements()->list('tags', ['groupId' => $this->tagGroup->id])->structuredContent['elements'], 'title')); + } + + public function testGlobalSetsRequirePermissionToEdit(): void + { + $set = GlobalSet::find()->handle('aGlobalSet')->one(); + $deleted = GlobalSet::find()->handle('aDeletedGlobalSet')->trashed()->one(); + $user = new User(['username' => 'mcp-globals', 'email' => 'mcp-globals@example.com', 'active' => true]); + self::assertTrue(Craft::$app->getElements()->saveElement($user, false)); + Craft::$app->getUserPermissions()->saveUserPermissions($user->id, ['accesscp']); + Craft::$app->getUser()->setIdentity(User::find()->id($user->id)->one()); + + // Global sets’ canSave() allows everyone, but the control panel requires permission to edit each set + $this->assertFails(fn() => $this->elements()->update('globalSets', $set->id, fields: ['plainTextField3' => 'Changed'])); + $this->assertFails(fn() => $this->elements()->get('globalSets', $set->id)); + + Craft::$app->getUserPermissions()->saveUserPermissions($user->id, ['accesscp', "editglobalset:$set->uid"]); + Craft::$app->getUser()->setIdentity(User::find()->id($user->id)->one()); + $this->elements()->update('globalSets', $set->id, fields: ['plainTextField3' => 'Changed']); + self::assertSame('Changed', GlobalSet::find()->id($set->id)->one()->getFieldValue('plainTextField3')); + + // Deleted global sets come back through project config, not by restoring their elements + Craft::$app->getUser()->setIdentity(User::find()->admin()->one()); + $this->assertFails(fn() => $this->elements()->restore('globalSets', $deleted->id)); + } + + private function assertFails(callable $callback): void + { + $failed = false; + + try { + $callback(); + } catch (ToolCallException) { + $failed = true; + } + + self::assertTrue($failed, 'Expected the tool call to fail.'); + } + + private function elements(): Elements + { + return Craft::$app->get('mcp')->getCapabilityContainer()->get(Elements::class); + } +} From c3ac2a3a64a475c2485c2906f65f6960f9a0abe4 Mon Sep 17 00:00:00 2001 From: Brandon Kelly Date: Sat, 7 Nov 2026 22:35:01 +0000 Subject: [PATCH 2/2] Address review of category and tag MCP adapters - Load disabled categories, tags, and saved category drafts by ID or UID - Require editSite to delete categories, as the control panel does - Accept context.siteId for new categories' field schemas Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8 Co-authored-by: Amp --- src/mcp/elements/adapters/CategoryAdapter.php | 23 ++++++++++++++++++- src/mcp/elements/adapters/TagAdapter.php | 6 +++++ tests/unit/mcp/ElementAdaptersTest.php | 2 ++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/mcp/elements/adapters/CategoryAdapter.php b/src/mcp/elements/adapters/CategoryAdapter.php index 0a41ffeef0b..308241d3391 100644 --- a/src/mcp/elements/adapters/CategoryAdapter.php +++ b/src/mcp/elements/adapters/CategoryAdapter.php @@ -7,6 +7,7 @@ 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; @@ -66,6 +67,7 @@ protected function fieldSchemaContextSchema(): array 'description' => 'For a new category, provide groupId.', 'properties' => [ 'groupId' => ['type' => 'integer', 'description' => 'Category group ID of a new category.'], + 'siteId' => ['type' => 'integer', 'description' => 'Site ID of a new category.'], ], 'additionalProperties' => false, ]; @@ -85,6 +87,12 @@ public function fieldSchemaElement(?ElementInterface $element, array $context, U 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') { @@ -97,13 +105,26 @@ public function duplicate(ElementInterface $element, ?string $mode): ElementInte /** @param Category $element */ protected function authorizeSave(User $actor, ElementInterface $element): void { - if (Craft::$app->getIsMultiSite() && !$actor->can("editSite:{$element->getSite()->uid}")) { + 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> */ private function sharedAttributeProperties(): array { diff --git a/src/mcp/elements/adapters/TagAdapter.php b/src/mcp/elements/adapters/TagAdapter.php index 5eedebe2889..661c2683069 100644 --- a/src/mcp/elements/adapters/TagAdapter.php +++ b/src/mcp/elements/adapters/TagAdapter.php @@ -5,6 +5,7 @@ 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; @@ -78,4 +79,9 @@ public function fieldSchemaElement(?ElementInterface $element, array $context, U return parent::fieldSchemaElement($element, $context, $actor); } + + protected function findQuery(): ElementQueryInterface + { + return Tag::find()->status(null); + } } diff --git a/tests/unit/mcp/ElementAdaptersTest.php b/tests/unit/mcp/ElementAdaptersTest.php index 49ac3bc513a..7c6f6b8a67a 100644 --- a/tests/unit/mcp/ElementAdaptersTest.php +++ b/tests/unit/mcp/ElementAdaptersTest.php @@ -50,6 +50,8 @@ public function testCategoriesAndTagsCanBeCreatedListedAndRead(): void $categories = $this->elements()->list('categories', ['group' => 'mcpTopics'])->structuredContent['elements']; self::assertSame([$category['id']], array_column($categories, 'id')); + // Disabled categories can still be read and updated + $this->elements()->update('categories', $category['id'], attributes: ['enabled' => false]); self::assertSame('Releases', $this->elements()->get('categories', $category['id'])['element']['title']); self::assertSame(['craft6'], array_column($this->elements()->list('tags', ['groupId' => $this->tagGroup->id])->structuredContent['elements'], 'title')); }