Repository navigation
[5.x] Make MCP upload URLs work over stdio and on control panel endpoints - #20468
brandonkelly wants to merge 3 commits into
Conversation
The stdio server can only read local paths within its own project, so agents whose files it can't see, such as when it runs in a container, had no way to upload a large local file. Upload URLs point at the site's web server, so they work for stdio sessions too. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8
There was a problem hiding this comment.
🟡 Changes recommended
Stdio upload URL generation can drop the site subdirectory or return a hostless relative URL, preventing clients from reaching the upload endpoint.
2 open issues
What changed in this PR
This PR exposes assets_upload_url over stdio so MCP clients can upload files that the server cannot read directly, including files outside a container.
Changes:
- Removes the upload-URL tool’s HTTP-only restriction.
- Updates server instructions and release notes to describe stdio support.
- Adds a stdio regression test covering URL generation, token routing, and uploaded-file consumption.
| File | Description |
|---|---|
tests/unit/mcp/AssetFieldUploadTest.php |
Adds stdio upload-URL coverage and generalizes the tool-call helper. |
src/mcp/capabilities/Assets.php |
Makes assets_upload_url available over stdio. |
src/mcp/AdminInstructions.php |
Documents upload URLs for files inaccessible by server-local paths. |
CHANGELOG.md |
Announces stdio upload-URL support. |
Review effort: Balanced
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
Over stdio, console URLs lacked the host and the site's subdirectory, or were relative, so upload URLs now use the site's base URL and the tool fails clearly when it isn't absolute. Over HTTP, upload URLs for an authenticated endpoint within the control panel 404ed, because the upload action was limited to the front end along with the public server. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8
Tokenized URLs' scheme follows useSslOnTokenizedUrls, so only check the upload URL's host and path. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8

Summary
Why craftcom local had no
assets_upload_url: its MCP server is still on the 5.12.0.1 release code. Thatassets_createonly takes{download_url, file_id}("temporary HTTPS file reference"), and tools likeuser_permissions_user_updateare also missing. craftcom staging runs current5.x, which includes #20377 (base64data, stdiopath, andassets_upload_url). Updating craftcom's localcraftcms/cmsbrings the upload-URL flow back.This PR fixes two remaining gaps so upload URLs work wherever
assets_createdoes:1. Stdio.
assets_upload_urlwas#[RequiresHttp]. Over stdio, the only way to send a large local file waspath, and that only works when the server can read the file inside its own project. That fails when the server runs in a container (for exampleddev craft mcp/serve) or the file is elsewhere. Console requests can't build the HTTP endpoint's URL:UrlHelperdrops the host and subdirectory, producing/index.php?p=…orhttps://example.test/index.php?p={cpTrigger}/mcpfor a site athttps://example.test/craft/. So over stdio the URL is built from the site's base URL, and the token routes it tomcp/uploadwhatever the path. If that base URL isn't absolute, the tool fails before reserving anything.2. Control panel endpoints. Upload URLs use the authenticated server's endpoint, which defaults to
/{cpTrigger}/mcpwhen public registration is off.McpController::beforeAction()restricts every action exceptauthenticatedto the front end. That rule is meant for the public server and discovery, but it also 404eduploadon control panel requests.uploadnow joinsauthenticated. It is still authorized only by its single-use token.The token, single use, 15-minute expiry,
saveAssetscheck, and per-user claim are unchanged.Craft 6:
craftcms/cms6.xhas a different design.assets_upload_prepareis a resumable upload authenticated with the MCP bearer token, deliberately#[RequiresHttp], and its description directs stdio clients to send the file inline. So the stdio change doesn't port directly; whether Craft 6 should offer a stdio upload path is a separate decision. The control panel 404 is specific to Craft 5'sMcpController.Limitation: over stdio, the upload goes to the site's base URL. On a headless site whose base URL points at a separate front-end app, that won't reach Craft.
Evidence
AssetFieldUploadTest::testUploadUrlsWorkOverStdio(omitScriptNameInUrls: false):Tool not found: "assets_upload_url". With only#[RequiresHttp]removed, the root-relative case returns a relative URL (Failed asserting that false is true), and the subdirectory case returnshttps://example.test/index.php?p={cpTrigger}/mcp&token=….After:
OK (6 tests, 22 assertions)McpServersCest::testUploadUrlsWorkOnControlPanelEndpoints: PUT to/{cpTrigger}/mcp?token=<mcp/upload token>Expected HTTP Status Code: 400 (Bad Request). Actual Status Code: 404 (Not Found), thrown atMcpController.php:58After: 400 from the upload action, which rejects the unreserved upload.
McpServersCestpasses (9 tests).vendor/bin/codecept run unit mcppasses (71 tests). ECS and Rector are clean. PHPStan passes (pre-commit hook).