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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- 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))
- The `asset_folders_list` MCP tool now accepts `null` for `criteria.parentId`, which lists volumes’ root folders.
- `craft\elements\Asset::canSave()` now returns `true` for users’ own assets in their temporary upload folder.
- The **My Account** → **Tokens** screen is now only shown to admins when OAuth signing keys haven’t been set up yet, and explains how to set them up, rather than failing with a server error when a token is created. Requests to create tokens are now rejected with a 400 error until the keys are set up. ([#20357](https://github.com/craftcms/cms/issues/20357))
- Fixed a bug where saving user or group permissions dropped stored permissions whose plugins hadn’t registered them for the current request, such as when using the `user_permissions_group_set` MCP tool over stdio. The MCP permission tools now also reject unknown permissions rather than ignoring them. ([#20434](https://github.com/craftcms/cms/pull/20434))
Expand Down
8 changes: 7 additions & 1 deletion src/mcp/capabilities/AssetFolders.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
'id' => ['type' => 'integer'],
'uid' => ['type' => 'string', 'format' => 'uuid'],
'volumeId' => ['type' => 'integer'],
'parentId' => ['type' => 'integer', 'description' => 'Parent folder ID.'],
'parentId' => ['type' => ['integer', 'null'], 'description' => 'Parent folder ID, or null for volumes’ root folders. List a volume’s top-level folders with its root folder’s ID.'],

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 52b6968: the test now calls asset_folders_list through tools/call on the authenticated server, so the input schema applies. It fails both when parentId reverts to integer-only (-32602 … Expected 'integer', but received 'null') and when the :empty: mapping is removed (the subfolder is included).

'name' => ['type' => 'string', 'description' => 'Folder name.'],
'path' => ['type' => 'string', 'description' => 'Folder path.'],
'offset' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Number of folders to skip.'],
Expand Down Expand Up @@ -58,6 +58,12 @@ public function list(
): array {
$actor = $this->actor->user();
$criteria['limit'] = max(1, min(500, (int)($criteria['limit'] ?? 100)));

// Folder criteria ignore a null parentId, so match root folders explicitly
if (array_key_exists('parentId', $criteria) && $criteria['parentId'] === null) {
$criteria['parentId'] = ':empty:';
}

$folders = array_values(array_filter(
$this->folders->findFolders($criteria),
static fn(VolumeFolder $folder): bool => Authorization::allows($actor, 'viewContents', $folder),
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/mcp/AssetFoldersCapabilityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace crafttests\unit\mcp;

use Craft;
use craft\elements\User;
use craft\mcp\HttpTransportMiddleware;
use craft\mcp\ServerFactory;
use craft\models\VolumeFolder;
use craft\services\Mcp;
use craft\test\TestCase;
use crafttests\fixtures\VolumesFolderFixture;
use GuzzleHttp\Psr7\ServerRequest;
use Mcp\Server\Transport\StreamableHttpTransport;

/** Exercises the asset folder tools through the authenticated MCP server, so their input schemas apply. */
class AssetFoldersCapabilityTest extends TestCase
{
private mixed $originalIdentity;

public function _fixtures(): array
{
return ['folders' => ['class' => VolumesFolderFixture::class]];
}

protected function _before(): void
{
$this->originalIdentity = Craft::$app->getUser()->getIdentity();
Craft::$app->getUser()->setIdentity(User::find()->admin()->one());
}

protected function _after(): void
{
Craft::$app->getUser()->setIdentity($this->originalIdentity);
}

public function testListFiltersToRootFoldersForNullParentId(): void
{
Craft::$app->getAssets()->storeFolderRecord(new VolumeFolder([
'name' => 'Subfolder',
'parentId' => 1000,
'volumeId' => 1000,
'path' => 'test volume 1/subfolder/',
]));

$result = $this->callTool('asset_folders_list', ['criteria' => ['parentId' => null]]);

self::assertFalse($result['isError'] ?? true, json_encode($result));
self::assertEqualsCanonicalizing([1000, 1001], array_column($result['structuredContent']['folders'], 'id'));
}

private function callTool(string $name, array $arguments): array
{
Craft::$app->get('mcp')->tokenScopes = [Mcp::SCOPE_READ];
$container = Craft::$app->get('mcp')->getCapabilityContainer();
$request = new ServerRequest('POST', 'http://localhost/mcp', [
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'MCP-Protocol-Version' => '2026-07-28',
'Mcp-Method' => 'tools/call',
'Mcp-Name' => $name,
], json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [
'name' => $name,
'arguments' => $arguments,
'_meta' => [
'io.modelcontextprotocol/protocolVersion' => '2026-07-28',
'io.modelcontextprotocol/clientCapabilities' => (object)[],
],
]]));
$response = $container->get(ServerFactory::class)->authenticated()->run(new StreamableHttpTransport(
request: $request,
middleware: $container->get(HttpTransportMiddleware::class)->forRequest($request),
));
$response = json_decode((string)$response->getBody(), true);

return $response['result'] ?? $response;
}
}
Loading