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..308241d3391 --- /dev/null +++ b/src/mcp/elements/adapters/CategoryAdapter.php @@ -0,0 +1,138 @@ + [...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.'], + '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 */ + 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> */ + 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..661c2683069 --- /dev/null +++ b/src/mcp/elements/adapters/TagAdapter.php @@ -0,0 +1,87 @@ + [...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); + } +} diff --git a/tests/unit/mcp/ElementAdaptersTest.php b/tests/unit/mcp/ElementAdaptersTest.php new file mode 100644 index 00000000000..7c6f6b8a67a --- /dev/null +++ b/tests/unit/mcp/ElementAdaptersTest.php @@ -0,0 +1,99 @@ + ['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')); + // 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')); + } + + 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); + } +}