Bypassing the MCP Limit: Images in Plane Comments
The Plane MCP server has no upload action and the SSRF guard rightly rejects private URLs. The self-host answer: drop one level and create the FileAsset inside the container.
TL;DR
The Plane MCP server can't upload files and its SSRF guard correctly blocks loopback URLs and internal API keys. The workaround is a tiny Python script that copies the image into the container and uploads it to MinIO via Django shell. Then you patch the comment's HTML with the returned URL, which renders in the UI but stays private.
A practical guide to attaching images to Plane comments when the official MCP server lacks an upload tool, using a custom Docker-based helper script.
plane-comment-image-upload-en
I was trying to pin a screenshot of my latest test results into a Plane comment using an AI agent. I typed the command, waited for the attachment to appear, and got nothing. The official Plane MCP server simply has no file-upload action [9](https://github.com/makeplane/plane-mcp-server). It can read and manage projects, work items, cycles, and modules, but it fundamentally cannot handle binary file ingestion or local file system access.
Two rejections, both correct
My first instinct was to host the image on my local development machine and pass its loopback URL to the workitem_attachment upload_from_url action. I figured the agent could just fetch it from my local server. When that failed, I tried hitting the internal /api/ endpoints directly, assuming my X-Api-Key would breeze right through the authentication layer and force the upload process.
Both attempts failed hard, and the error logs made the reason immediately clear. The upload_from_url action explicitly refuses private or loopback URLs. This is not a bug or an oversight; it is a correct and necessary SSRF guard [8](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html). Server-Side Request Forgery abuses an application to reach internal networks or the machine itself, and URL mishandling is the primary enabler for such attacks. For example, an image URL the app is asked to download could be a trap designed to hit internal metadata services. Furthermore, the app-internal /api/ endpoints reject X-Api-Key authentication entirely. That specific key is strictly reserved for the public /api/v1 endpoints [7](https://developers.plane.so/api-reference/introduction). The system was working exactly as designed to keep my instance secure.
When an abstraction layer lacks a specific capability, the solution in a self-hosted environment is to drop one level down. The container is the API. Instead of fighting the SSRF guard or trying to spoof public URLs with external tunneling tools, I respected the boundary and wrote a helper script: scripts/attach_comment_image.py.
This script is deliberately lightweight, relying only on the Python standard library and the Docker CLI. It does not need complex third-party dependencies. It copies the target image directly into the plane-api container and executes a Django shell command inside that isolated environment. The shell creates a FileAsset database row with entity_type set to COMMENT_DESCRIPTION, binding it to the specific project_id and comment_id at the exact moment of creation. It then reads the raw file bytes and uploads them to the MinIO bucket via the internal S3Storage backend. Finally, it sets is_uploaded=True and prints exactly one JSON line to standard output, containing the asset_id, asset_url, and url.
To make this work reliably in practice, I follow a strict three-step procedure.
First, I create the comment via the MCP using the workitem_comment action with action=create. This step is mandatory because the asset must bind to the comment ID, not the parent work item. You cannot attach an image to a comment that does not exist yet in the database.
Second, I run the helper script from my host machine, passing the newly created comment ID and the local image path. The script handles the container boundary crossing seamlessly, and I parse the single JSON line it outputs to grab the generated URL.
Third, I update the comment using the MCP, injecting the returned URL into the comment_html field as <p><img src="URL" width="640"></p>. This specific field choice is critical. The comment_html field preserves the <img> tag for proper visual rendering, whereas standard description fields aggressively strip all HTML for security reasons. If you try to inject HTML into the standard body field, the system will silently sanitize it, leaving you with broken text instead of an image.
You might notice that an anonymous GET request to the generated asset URL returns a 401 Unauthorized response. This is entirely expected and correct. The Plane web UI fetches the image seamlessly using the active browser session cookies, so no public anonymous access is required or granted.
Solving this upload workflow required accepting that the MCP is just a facade. When the facade lacks a tool, the underlying self-hosted infrastructure is always there to fill the gap, provided you know how to talk to it directly.