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,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))
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/controllers/McpController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/AdminInstructions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 14 additions & 3 deletions src/mcp/capabilities/Assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 "<upload_url>"), 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

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 da4eb51. Over stdio, upload URLs now use the site's base URL, which keeps the host and subdirectory. The token routes the upload whatever the path. The test now runs with omitScriptNameInUrls: false and a https://example.test/craft/ base URL. The old code produced https://example.test/index.php?p={cpTrigger}/mcp&token=… there.

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 da4eb51. Over stdio, assets_upload_url now fails before reserving anything when the site's base URL isn't absolute: "Upload URLs over stdio require the site to have an absolute base URL." The test covers a / base URL.

{
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);
}

/**
Expand Down
12 changes: 12 additions & 0 deletions tests/functional/McpServersCest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
54 changes: 51 additions & 3 deletions tests/unit/mcp/AssetFieldUploadTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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 ([
Expand All @@ -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");
}
Expand All @@ -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.");
}
}
Loading