Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions src/mcp/Authorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)) {
Expand Down
13 changes: 12 additions & 1 deletion src/mcp/elements/ElementAdapterRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = [];
Expand Down
138 changes: 138 additions & 0 deletions src/mcp/elements/adapters/CategoryAdapter.php
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

Copy link
Copy Markdown
Member Author

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), as EntryAdapter does, and tags with status(null). Listing defaults are unchanged. The test now disables the category and reads it back. It fails without this change ("Category not found.").

{
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.'],
Comment thread
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 */

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c3ac2a3: CategoryAdapter::canDelete() now requires editSite for the category's site on multi-site installs, as well as the native delete permission. This matches CategoriesController::_enforceEditCategoryPermissions(). One correction: Category::canView() doesn't check site access, so the check is explicit rather than going through canView().

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.'],
];
}
}
72 changes: 72 additions & 0 deletions src/mcp/elements/adapters/GlobalSetAdapter.php
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);
}
}
87 changes: 87 additions & 0 deletions src/mcp/elements/adapters/TagAdapter.php
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);
}
}
Loading
Loading