Skip to content

feat(mcp): add media_upload tool for programmatic media management#1954

Open
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:feat/mcp-media-upload
Open

feat(mcp): add media_upload tool for programmatic media management#1954
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:feat/mcp-media-upload

Conversation

@swissky

@swissky swissky commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a media_upload MCP tool so agent workflows can get file bytes into the media library without dropping to the CLI or the raw multipart API (which needs an active session, CSRF headers, and multipart form data).

Design discussion: #1953

The tool accepts either base64-encoded file contents or a public url to fetch from (exactly one of the two), plus filename, optional contentType (required with base64; with url it defaults to the response Content-Type), and optional alt. It returns the media item with id, storageKey, and a relative url — ready to reference from content fields via content_create / content_update, exactly as requested in #620.

The handler (api/handlers/media-upload.ts) runs the same pipeline as the REST POST /_emdash/api/media route:

  • Global MIME allowlist (image/, video/, audio/, application/pdf) — no field-specific widening over MCP
  • maxUploadSize from the integration config, with cheap prechecks (encoded-string length, Content-Length) before buffering
  • Content-hash dedupe — identical bytes return the existing item with deduplicated: true instead of a duplicate record
  • Storage upload with cleanup — the object is deleted again if record creation fails
  • Image enrichment — dimensions, blurhash, dominant color via the shared enrichImageMetadata

Security:

  • URL fetches go through the existing ssrfSafeFetch: private/internal hosts are rejected before any request, redirects are re-validated hop by hop, and credential headers are stripped on cross-origin redirects.
  • Authorization mirrors the REST route: media:write scope plus Contributor minimum role (the floor of the media:upload permission in rbac.ts).

Also updates the media_create tool description and the MCP reference docs to point binary uploads at the new tool, and adds a byte-level decodeBase64Bytes next to the existing base64 utils.

Closes #620
Closes #1825

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Translation
  • Documentation
  • Performance
  • Tests
  • Chore

How was this change tested?

  • New unit suite tests/unit/api/handlers/media-upload.test.ts (10 tests, real SQLite + fake storage): base64 upload with enrichment, dedupe, exactly-one-of-base64/url validation, missing/invalid contentType and base64, allowlist rejection, size limit, URL fetch honoring the response Content-Type, HTTP error surfacing, and SSRF rejection of a private address (fetch never called)
  • New guard tests in tests/unit/mcp/authorization.test.ts: Subscriber role rejected, missing media:write scope rejected, NO_STORAGE when no storage adapter is configured
  • pnpm typecheck, pnpm lint, pnpm format all clean

Checklist

  • I have read CONTRIBUTING.md
  • I have added tests that prove my fix/feature works
  • New and existing tests pass locally
  • I have added a changeset (pnpm changeset) if this change affects published packages
  • For features: I have opened a Discussion and linked it here

AI-generated code disclosure

  • Portions of this PR were generated with an AI tool

Which tool? Cursor (Fable 5)

Adds a media_upload MCP tool that accepts base64-encoded file data or a
public URL, runs the same pipeline as the REST upload route (global MIME
allowlist, size limit, content-hash dedupe, storage upload, image
metadata enrichment), and returns the media item ready to reference from
content fields.

URL fetches go through ssrfSafeFetch so redirects and private hosts are
rejected. Closes emdash-cms#620, closes emdash-cms#1825.
@changeset-bot

changeset-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f91f8c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 582 lines across 8 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1954

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1954

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1954

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1954

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1954

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1954

emdash

npm i https://pkg.pr.new/emdash@1954

create-emdash

npm i https://pkg.pr.new/create-emdash@1954

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1954

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1954

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1954

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1954

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1954

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1954

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1954

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1954

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1954

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1954

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1954

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1954

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1954

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1954

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1954

commit: f91f8c7

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 11, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a well-scoped feature that follows the approved Discussion. The new media_upload MCP tool reuses EmDash’s existing SSRF fetch, global MIME allowlist, content-hash dedupe, storage cleanup on failure, and image-enrichment pipeline, and the authorization (media:write scope + Role.CONTRIBUTOR) is consistent with the RBAC floor for media:upload and the existing media_create MCP tool.

The tests cover the important paths (base64, URL, validation, allowlist, size limits, SSRF, dedupe, auth), and the docs/changeset are updated.

I found one real gap that should be fixed before merge: the MIME type string is never validated, so a crafted contentType can bypass the allowlist (normalizeMime only trims ends and matchesMimeAllowlist just checks startsWith) and reach the storage backend’s ContentType header, then be echoed by the media file serving route. I also suggested tightening the MCP input schema for contentType as minor hardening.

const { bytes } = acquired;
const mimeType = normalizeMime(acquired.mimeType);

if (!matchesMimeAllowlist(mimeType, GLOBAL_UPLOAD_ALLOWLIST)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The mimeType from base64 input and from the remote Content-Type header is normalized and allow-listed without validating the raw string first. normalizeMime only strips the parameter suffix and trims leading/trailing whitespace, and matchesMimeAllowlist only checks startsWith, so a value such as image/png\r\nX-Evil: 1 passes the allowlist and is later passed straight to storage.upload(...). That value reaches S3/R2 ContentType and is echoed by GET /_emdash/api/media/file/:key.

Re-use the existing CONTENT_TYPE_RE from api/schemas/media.ts (export it if necessary) and reject malformed values before the allowlist/storage check:

Suggested change
if (!matchesMimeAllowlist(mimeType, GLOBAL_UPLOAD_ALLOWLIST)) {
import { CONTENT_TYPE_RE, DEFAULT_MAX_UPLOAD_SIZE, formatFileSize } from "../schemas/media.js";
// ...
export async function handleMediaUpload(
db: Kysely<Database>,
storage: Storage,
input: MediaUploadInput,
): Promise<MediaUploadResult> {
if (!input.base64 === !input.url) {
return fail("VALIDATION_ERROR", "Provide exactly one of 'base64' or 'url'");
}
const rawMax = input.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
if (!Number.isFinite(rawMax) || rawMax <= 0) {
return fail("CONFIGURATION_ERROR", "Invalid maxUploadSize configuration");
}
const acquired = await acquireBytes(input, rawMax);
if ("success" in acquired) return acquired;
const { bytes } = acquired;
if (!CONTENT_TYPE_RE.test(acquired.mimeType)) {
return fail("VALIDATION_ERROR", "Invalid content type");
}
const mimeType = normalizeMime(acquired.mimeType);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f91f8c7 — exported CONTENT_TYPE_RE from api/schemas/media.ts and the handler now rejects the raw MIME string before normalize/allowlist, exactly as suggested. Added two regression tests: a crafted base64 contentType (image/png\r\nX-Evil: 1) and a malformed remote Content-Type header both fail with VALIDATION_ERROR and never reach storage.

.describe(
"Public http(s) URL to fetch the file from. Provide exactly one of base64 / url.",
),
contentType: z

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Consider tightening the MCP input schema so malformed input is rejected before the handler runs and before the server buffers/fetches any bytes. api/schemas/media.ts already has a suitable CONTENT_TYPE_RE.

Suggested change
contentType: z
contentType: z
.string()
.optional()
.regex(CONTENT_TYPE_RE, "Invalid content type")
.describe(
"MIME type (e.g. 'image/png'). Required with base64; with url it " +
"defaults to the response's Content-Type header.",
),

(You may also want .url() on the url field, although ssrfSafeFetch will reject invalid schemes/addresses too.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f91f8c7contentType now has .regex(CONTENT_TYPE_RE) and url has .url() in the tool input schema, so malformed input is rejected at the schema layer before any bytes are buffered or fetched. The handler-level check stays as defense in depth for the remote Content-Type header path.

The prefix-based allowlist check let a crafted contentType like
'image/png\r\nX-Evil: 1' through to the storage backend's ContentType
header (echoed by the media file route). Validate against the existing
CONTENT_TYPE_RE first, and tighten the MCP input schema (regex on
contentType, .url() on url) so malformed input is rejected before any
bytes are buffered or fetched.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jul 11, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 11, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right follow-up to the previous review. The approved Discussion asked for an MCP tool that can put binary media into the media library, and the implementation reuses EmDash’s existing SSRF-safe fetch, global MIME allowlist, content-hash dedupe, storage cleanup, and image-enrichment pipeline rather than inventing a parallel upload path. The prior MIME-validation gap is fixed: CONTENT_TYPE_RE is now exported from the shared media schema and applied both in the MCP input schema and in the handler before the MIME is normalized or allowlisted, so crafted contentType values with CR/LF or bogus tokens can no longer reach storage or the file-serving response headers.

What I checked:

  • Authorization: media:write is a real API-token scope with a CONTRIBUTOR floor, and the tool correctly checks requireScope("media:write") plus requireRole(Role.CONTRIBUTOR) before touching storage. The media_create tool stays at AUTHOR, matching the docs split between “upload” and “register”.
  • Security: URL fetches go through ssrfSafeFetch with redirect re-validation and credential stripping; private IP literals are rejected before fetch() is called. The handler validates the raw MIME string, runs the allowlist, enforces maxUploadSize, and cleans up the storage object if DB record creation fails.
  • Consistency: the returned URL shape (/_emdash/api/media/file/${storageKey}), dedupe behavior, image enrichment, and error envelope all mirror the REST POST /_emdash/api/media route.
  • Tests: the new handler tests cover base64/URL paths, validation, allowlist, size limits, SSRF, dedupe, and the prior MIME-injection case; the auth tests cover role, scope, and NO_STORAGE.
  • AGENTS.md conventions: no raw SQL, no locale-filter gap, no admin UI strings/Tailwind, changeset present.

No remaining blockers. LGTM.

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core area/docs bot:review Trigger an emdashbot code review on this PR needs-rebase overlap review/approved Approved; no new commits since size/XL

Projects

None yet

1 participant