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..c8dbed4bf6a --- /dev/null +++ b/tests/unit/mcp/AssetFoldersCapabilityTest.php @@ -0,0 +1,78 @@ + ['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; + } +}