Upload media for the issue agent

Changed

Context

The issue agent cannot attach images to tasks. This came up adding a cropped mockup to Inventory #341: the crop was made locally, but nothing could get it onto the site.

  • None of the six contrib_tasks tools creates media or files. task_update already passes <drupal-media> embeds through untouched, but only for media that already exists.
  • drupal-contrib-live runs drush @hetz.contrib mcp:server issue_agent on the deploy host over SSH (scripts/mcp-server.sh), so a tool cannot read a file path from the client's machine. The bytes would have to travel in the JSON-RPC request.
  • The issue_agent role (config/contrib/user.role.issue_agent.yml) has no media permissions.
  • Image media on contrib: source field field_media_image, extensions png gif jpg jpeg, directory [date:custom:Y]-[date:custom:m], alt text required, no maximum file size. The content format's media embed defaults to the screenshot view mode, so a bare embed renders at screenshot size.
  • Image media is named after its alt text by auto_entitylabel (auto_entitylabel.settings.media.image), and filefield_paths renames the file after it on save. A name passed in is overwritten.
  • tool_belt is in the codebase but not enabled on contrib. Its generic EntityCreate cannot carry file contents, so it would not solve this. It would also hand the agent generic tools that can delete entities, change user roles and send email.

Decisions

  • A drush command, not an MCP tool. An agent cannot attach a file to a tool call: it would have to write out every base64 character of the image as its own output, which is impractical past a small crop. A drush command reads the image from STDIN instead, and the agent runs it from its shell: drush @hetz.contrib contrib-tasks:upload-image mockup.png --alt="…" < mockup.png. Checked on 2026-10-23: 400 KB of binary piped into drush @hetz.contrib arrived byte for byte (matching sha256) in about two seconds, and drush runs as www-data in the container, the same owner as browser uploads.
  • The command acts as issue_agent. Drush runs as anonymous and entity saves check no access, so the command switches to the account (--as, default issue_agent) and checks createAccess('image') before saving. The role stays the whole policy, as for the tools, and the media is owned by the agent.
  • No --name option. auto_entitylabel names the media after the alt text whatever is passed.
  • The embed is per site. The UUID it returns only resolves on the site it was uploaded to: upload with @hetz.contrib for a task edited through drupal-contrib-live, with @ddev.contrib for the local copy.
  • No cleanup of orphans. If the upload works and the task_update after it does not, the media stays. It is harmless and can be deleted at /admin/content/media.

TODO

  • [x] Permission. Add create image media to config/contrib/user.role.issue_agent.yml, with media.type.image under config dependencies and media under module dependencies. Update the role list in the "The agent account" section of the contrib_tasks README.
  • [x] Service. ImageMediaUploader in contrib_tasks: takes the bytes, a filename, alt text and the account. Refuses an extension the image field does not allow, a file over the field's limit or 5 MB when it has none, bytes that are not an image or do not match the extension, and empty alt text, each with a message saying why. Writes the file to the field's upload directory and creates an image media entity owned by the account.
  • [x] Command. contrib-tasks:upload-image <filename> --alt= [--as=issue_agent], image on STDIN, JSON output by default: media id, uuid, name, the file URL and the embed string <drupal-media data-entity-type="media" data-entity-uuid="…"></drupal-media>.
  • [x] Kernel test for the service, on ContribTasksKernelTestBase.
  • [x] Docs. The command in the contrib_tasks README and docs/sites/contrib.md; a line in the MCP server instructions (mcp_server.settings:server_instructions) and in the refine-issue skill: "to attach an image, run the drush command, then embed what it prints with task_update".
  • [x] Embed in a paragraph. Markdown::toHtml() wrapped a <drupal-media> line in <p>, which CKEditor never stores and which renders as empty paragraphs around the image. It now stores the embed on its own.
  • [x] Permission for the agent. Allow exactly drush @hetz.contrib contrib-tasks:upload-image in Claude Code, not drush @hetz.*.
  • [x] Try it locally against @ddev.contrib.
  • [x] Deploy: commit, deploy to live, drush @hetz.contrib cim -y and cr.
  • [x] Reconnect: /mcp and reconnect drupal-contrib-live. The MCP server is one long-lived drush process, so until it restarts it runs the code it started with: here task_update kept wrapping embeds after the deploy.
  • [x] Repair #328 and #338. Earlier task_update calls left their embeds wrapped in <p> on live. After the deploy, an update of each body through the tool stores them unwrapped.

Done when

  • drush @hetz.contrib contrib-tasks:upload-image with a PNG on STDIN and alt text returns a media UUID and an embed string; the media appears at /admin/content/media, owned by issue_agent.
  • That embed added to a task body with task_update renders the image on the task page.
  • A file with a disallowed extension, an oversized file, bytes that are not an image, or a missing alt text is refused with a message saying why.
  • The cropped Today panel mockup is embedded in Inventory #341.

Done

  • 2026-10-23: built and tried locally against @ddev.contrib: a PNG upload returned the embed, the media was owned by issue_agent, and the embed rendered in the screenshot view mode. Missing alt text, a PNG named .jpg, empty STDIN and an unknown --as user were each refused with a message. The test media was deleted afterwards. contrib_tasks tests: 49 pass. phpcs and PHPStan are clean.
  • 2026-10-23, on live: the upload ran through the Claude Code allow rule without a prompt. A test PNG became media owned by issue_agent, its file served, and it rendered in the screenshot view mode; the test media was then deleted. Missing alt text, a PNG named .jpg and piped text were each refused with a message.
  • The Today panel mockup is media 119, embedded in #341's Context; the page shows it.
  • After reconnecting /mcp, #328, #338 and #341 were resaved through task_update. No task body on live holds <p><drupal-media any more, and each page still shows its image.
Task type
Topics
Modules