From 86cfb03e100a53dbdf6940059d97d48d5375041a Mon Sep 17 00:00:00 2001 From: Brandon Kelly Date: Sat, 7 Nov 2026 22:09:05 +0000 Subject: [PATCH 1/2] Let asset_folders_list filter to root folders criteria.parentId now accepts null, which lists volumes' root folders. Their IDs are needed to list a volume's top-level folders or create new ones with asset_folders_create. Folder criteria ignore a null parentId, so it's mapped to :empty:. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8 --- CHANGELOG.md | 1 + src/mcp/capabilities/AssetFolders.php | 8 +++- tests/unit/mcp/AssetFoldersCapabilityTest.php | 45 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/unit/mcp/AssetFoldersCapabilityTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ba20c85db..7131bcff18d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/src/mcp/capabilities/AssetFolders.php b/src/mcp/capabilities/AssetFolders.php index a6a28dd2ad3..96eddf690e8 100644 --- a/src/mcp/capabilities/AssetFolders.php +++ b/src/mcp/capabilities/AssetFolders.php @@ -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.'], 'name' => ['type' => 'string', 'description' => 'Folder name.'], 'path' => ['type' => 'string', 'description' => 'Folder path.'], 'offset' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Number of folders to skip.'], @@ -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), diff --git a/tests/unit/mcp/AssetFoldersCapabilityTest.php b/tests/unit/mcp/AssetFoldersCapabilityTest.php new file mode 100644 index 00000000000..e3eac44b59e --- /dev/null +++ b/tests/unit/mcp/AssetFoldersCapabilityTest.php @@ -0,0 +1,45 @@ + ['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/', + ])); + + $folders = Craft::$app->get('mcp')->getCapabilityContainer()->get(AssetFolders::class)->list(['parentId' => null])['folders']; + + self::assertEqualsCanonicalizing([1000, 1001], array_column($folders, 'id')); + } +} From 52b69688c6557941cff343b05cd0dccd7301a240 Mon Sep 17 00:00:00 2001 From: Brandon Kelly Date: Sat, 7 Nov 2026 22:14:07 +0000 Subject: [PATCH 2/2] Test root folder listing through the MCP server Calling AssetFolders::list() directly bypassed the tool's input schema, so the test wouldn't catch parentId reverting to integer-only. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8 --- tests/unit/mcp/AssetFoldersCapabilityTest.php | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/unit/mcp/AssetFoldersCapabilityTest.php b/tests/unit/mcp/AssetFoldersCapabilityTest.php index e3eac44b59e..c8dbed4bf6a 100644 --- a/tests/unit/mcp/AssetFoldersCapabilityTest.php +++ b/tests/unit/mcp/AssetFoldersCapabilityTest.php @@ -4,11 +4,16 @@ use Craft; use craft\elements\User; -use craft\mcp\capabilities\AssetFolders; +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; @@ -38,8 +43,36 @@ public function testListFiltersToRootFoldersForNullParentId(): void 'path' => 'test volume 1/subfolder/', ])); - $folders = Craft::$app->get('mcp')->getCapabilityContainer()->get(AssetFolders::class)->list(['parentId' => null])['folders']; + $result = $this->callTool('asset_folders_list', ['criteria' => ['parentId' => null]]); - self::assertEqualsCanonicalizing([1000, 1001], array_column($folders, 'id')); + 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; } }