diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ba20c85db..a12e7385da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - 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 `assets_upload_url` MCP tool is now available over stdio (`mcp/serve`), for local files the server can’t read by path, such as when it runs in a container. Over stdio, upload URLs use the site’s base URL, which must be absolute. - 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)) @@ -28,6 +29,7 @@ - Fixed a bug where authenticated GraphQL requests didn’t respect field layout conditions. Fields that are hidden from the user are now excluded from the schema, or resolve to `null` when their conditions are element-specific. Fields the user can’t edit are now excluded from mutation arguments, or result in an error when set if their conditions are element-specific. ([#20391](https://github.com/craftcms/cms/pull/20391)) - Fixed a bug where field values submitted for nested entries, Content Block fields, and addresses could be saved even if the user wasn’t permitted to see or edit the fields, per their field layout conditions. ([#20391](https://github.com/craftcms/cms/pull/20391)) - Fixed a bug where MCP tools returned empty arrays for CKEditor field values. +- Fixed a bug where upload URLs from the `assets_upload_url` MCP tool returned 404 errors when the authenticated MCP server’s endpoint was within the control panel. - Added `craft\base\McpOutputFieldInterface`, which fields can implement to control how their values are returned by MCP tools. - Added `craft\mcp\McpOutputContext`. - MCP `fields` arguments now accept dot paths, such as `blocks.innerBlocks`, which include nested fields of nested elements as summaries. diff --git a/src/controllers/McpController.php b/src/controllers/McpController.php index 1f03dcf5ce7..22f1e24c269 100644 --- a/src/controllers/McpController.php +++ b/src/controllers/McpController.php @@ -53,8 +53,8 @@ public function beforeAction($action): bool } // The public server and discovery only live on the front end, as with their routes in Settings::requestRoute(), - // and not on the control panel’s own host - if ($action->id !== 'authenticated' && ($this->request->getIsCpRequest() || $settings->isCpHostRequest())) { + // and not on the control panel’s own host. Upload URLs use the authenticated server’s endpoint, wherever it is. + if (!in_array($action->id, ['authenticated', 'upload'], true) && ($this->request->getIsCpRequest() || $settings->isCpHostRequest())) { throw new NotFoundHttpException(); } diff --git a/src/mcp/AdminInstructions.php b/src/mcp/AdminInstructions.php index d9a5a2a3bab..54c83e383ab 100644 --- a/src/mcp/AdminInstructions.php +++ b/src/mcp/AdminInstructions.php @@ -76,7 +76,7 @@ public function get(): string - Read elements_schema. Before writing custom fields, get elements_field_schema with the target ID and siteId or the new element's required context; this requires save permission. Send built-in values in attributes and custom values in fields keyed by handle, following relation and nested-entry schemas. - Entry elements_get, elements_update, and elements_field_schema resolve canonical entries and saved drafts by their own element identifiers, excluding provisional drafts and revisions. Canonical identifiers select originals; draft element identifiers select drafts, and updating a draft leaves its canonical entry unchanged. elements_create with asUnpublishedDraft creates a new entry as an unpublished draft. drafts_list, drafts_create, and revisions_list take canonical identifiers; drafts_apply, drafts_delete, revisions_get, and revisions_apply take draft/revision element identifiers. draftId and revisionId identify separate records. Use identifiers returned by the appropriate tool. - elements_validate checks live rules without saving; passing does not guarantee a later save. After creates or updates, inspect the saved target and its canonical or draft state before declaring completion. Entry saves can return savedAsDraft. - - For duplication, follow entry elements_schema notes. For asset uploads or replacement, pass a file reference with download_url, data (base64), path (stdio only), or upload_id (from assets_upload_url, for local or large files over HTTP), following assets_create and assets_replace schemas. To upload for an Assets field, target its fieldId to use the field’s default upload location. Read current configuration and schemas before changes; project_config_apply and project_config_write describe the YAML workflow. user_permissions_user_set and user_permissions_group_set describe permission replacement. + - For duplication, follow entry elements_schema notes. For asset uploads or replacement, pass a file reference with download_url, data (base64), path (stdio only), or upload_id (from assets_upload_url, for local or large files, or files the stdio server can’t read by path), following assets_create and assets_replace schemas. To upload for an Assets field, target its fieldId to use the field’s default upload location. Read current configuration and schemas before changes; project_config_apply and project_config_write describe the YAML workflow. user_permissions_user_set and user_permissions_group_set describe permission replacement. Safety: - Default to soft deletion; hardDelete: true is permanent. asset_folders_delete permanently deletes contained assets even with deleteDirectory: false; read its schema for filesystem effects. diff --git a/src/mcp/capabilities/Assets.php b/src/mcp/capabilities/Assets.php index 38008be4dbf..6cd52715fab 100644 --- a/src/mcp/capabilities/Assets.php +++ b/src/mcp/capabilities/Assets.php @@ -10,8 +10,8 @@ use craft\errors\InvalidFsException; use craft\errors\InvalidSubpathException; use craft\fields\Assets as AssetsField; +use craft\helpers\UrlHelper; use craft\mcp\AssetUploads as McpAssetUploads; -use craft\mcp\attributes\RequiresHttp; use craft\mcp\Authorization; use craft\mcp\ElementResourceLinks; use craft\mcp\elements\adapters\AssetAdapter; @@ -313,10 +313,21 @@ public function replace( name: 'assets_upload_url', description: 'Gets a single-use URL for uploading a local or large file. Send the raw file bytes as the body of a PUT request to upload_url before expires_at (for example, curl -T ./photo.jpg ""), then pass {"upload_id": "...", "file_name": "photo.jpg"} as the file for assets_create or assets_replace. The file name is required, since it can’t be derived from the upload. Requires permission to save assets in at least one volume.', )] - #[RequiresHttp] public function uploadUrl(): array { - return $this->mcpAssetUploads->createUpload($this->actor->user(), $this->settings->url($this->settings->route)); + if (!Craft::$app->getRequest()->getIsConsoleRequest()) { + return $this->mcpAssetUploads->createUpload($this->actor->user(), $this->settings->url($this->settings->route)); + } + + // Console URLs are built without a host or the site's subdirectory, so the stdio server uses the site's base + // URL. The token routes the upload, whatever the path. + $baseUrl = UrlHelper::baseSiteUrl(); + + if (!UrlHelper::isAbsoluteUrl($baseUrl)) { + throw new ToolCallException('Upload URLs over stdio require the site to have an absolute base URL.'); + } + + return $this->mcpAssetUploads->createUpload($this->actor->user(), $baseUrl); } /** diff --git a/tests/functional/McpServersCest.php b/tests/functional/McpServersCest.php index 8ca554c9e65..51be7a5ebee 100644 --- a/tests/functional/McpServersCest.php +++ b/tests/functional/McpServersCest.php @@ -116,6 +116,18 @@ public function testControlPanelEndpointsRequireControlPanelAccess(FunctionalTes $I->dontSeeResponseCodeIs(403); } + public function testUploadUrlsWorkOnControlPanelEndpoints(FunctionalTester $I): void + { + $general = Craft::$app->getConfig()->getGeneral(); + $this->settings(['enabled' => true, 'route' => "/$general->cpTrigger/mcp", 'authorizationRoute' => "/$general->cpTrigger/mcp"]); + + // Upload URLs use the authenticated server's endpoint, and their token routes them to the upload action, + // which rejects this unreserved upload rather than the request 404ing + $token = Craft::$app->getTokens()->createToken(['mcp/upload', ['uploadId' => StringHelper::UUID()]]); + $I->sendAjaxPostRequest("/$general->cpTrigger/mcp?$general->tokenParam=$token"); + $I->seeResponseCodeIs(400); + } + public function testSiteEndpointsDoNotRequireControlPanelAccess(FunctionalTester $I): void { $this->settings(['enabled' => true, 'route' => '/mcp']); diff --git a/tests/unit/mcp/AssetFieldUploadTest.php b/tests/unit/mcp/AssetFieldUploadTest.php index 368585ffd09..e36896bece5 100644 --- a/tests/unit/mcp/AssetFieldUploadTest.php +++ b/tests/unit/mcp/AssetFieldUploadTest.php @@ -126,6 +126,49 @@ public function testUploadUrlsRequirePermissionToSaveAssets(): void } } + public function testUploadUrlsWorkOverStdio(): void + { + // Agents may not be able to give the stdio server a path it can read, such as when it runs in a container. + // Console URLs lack a host and subdirectory, so the URL must come from the site's base URL. + $general = Craft::$app->getConfig()->getGeneral(); + $site = Craft::$app->getSites()->getCurrentSite(); + $omitScriptName = $general->omitScriptNameInUrls; + $baseUrl = $site->getBaseUrl(false); + $general->omitScriptNameInUrls = false; + + try { + $site->setBaseUrl('/'); + $result = $this->callTool('assets_upload_url', []); + self::assertTrue($result['isError'] ?? false, json_encode($result)); + self::assertStringContainsString('absolute base URL', $result['content'][0]['text']); + + $site->setBaseUrl('https://example.test/craft/'); + $upload = $this->callTool('assets_upload_url', []); + } finally { + $general->omitScriptNameInUrls = $omitScriptName; + $site->setBaseUrl($baseUrl); + // The server saved the fixtures' pending config changes and released their lock within the test + // transaction, so take it again, or fixture cleanup will find the config stale once it's rolled back + Craft::$app->getProjectConfig()->acquireLock(); + } + + self::assertFalse($upload['isError'] ?? true, json_encode($upload)); + $upload = $upload['structuredContent']; + // The scheme follows useSslOnTokenizedUrls + self::assertSame(['example.test', '/craft/'], [parse_url($upload['upload_url'], PHP_URL_HOST), parse_url($upload['upload_url'], PHP_URL_PATH)]); + parse_str((string)parse_url($upload['upload_url'], PHP_URL_QUERY), $query); + $token = $query[$general->tokenParam]; + self::assertSame(['mcp/upload', ['uploadId' => $upload['upload_id']]], Craft::$app->getTokens()->getTokenRoute($token)); + + $uploads = Craft::$app->get('mcp')->getCapabilityContainer()->get(AssetUploads::class); + $stream = fopen($this->file, 'rb'); + $uploads->receiveUpload($upload['upload_id'], $stream); + fclose($stream); + + $contents = $uploads->consumeFile(['upload_id' => $upload['upload_id']], 'uploaded.png', static fn(string $path): string => file_get_contents($path)); + self::assertSame(file_get_contents($this->file), $contents); + } + /** @dataProvider restrictedPaths */ public function testRejectsPathsWhereVolumesCannotLive(string $path): void { @@ -154,6 +197,11 @@ public static function restrictedPaths(): array } private function createAsset(array $arguments): array + { + return $this->callTool('assets_create', $arguments); + } + + private function callTool(string $name, array $arguments): array { $input = fopen('php://memory', 'r+'); foreach ([ @@ -163,7 +211,7 @@ private function createAsset(array $arguments): array 'clientInfo' => ['name' => 'asset-field-upload-test', 'version' => '1.0'], ]], ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], - ['jsonrpc' => '2.0', 'id' => 'create', 'method' => 'tools/call', 'params' => ['name' => 'assets_create', 'arguments' => $arguments]], + ['jsonrpc' => '2.0', 'id' => 'call', 'method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => $arguments]], ] as $message) { fwrite($input, json_encode($message) . "\n"); } @@ -174,12 +222,12 @@ private function createAsset(array $arguments): array foreach (array_filter(explode("\n", (string)file_get_contents($outputPath))) as $line) { $response = json_decode($line, true); - if (($response['id'] ?? null) === 'create') { + if (($response['id'] ?? null) === 'call') { unlink($outputPath); return $response['result'] ?? $response; } } - self::fail('assets_create returned no response.'); + self::fail("$name returned no response."); } }