-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(mcp): add media_upload tool for programmatic media management #1954
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "emdash": minor | ||
| --- | ||
|
|
||
| Adds a `media_upload` MCP tool that uploads a file from base64-encoded data or a public URL and registers it in the media library, so agent workflows can create media without dropping to the CLI or raw API. Uploads are deduplicated by content hash and respect the global MIME allowlist and maximum upload size. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| /** | ||
| * Programmatic media upload handler (MCP `media_upload` tool). | ||
| * | ||
| * Accepts file bytes as base64 or fetches them from an external URL | ||
| * (SSRF-guarded), then runs the same pipeline as the multipart REST | ||
| * upload route: allowlist + size validation, content-hash deduplication, | ||
| * storage upload, image metadata enrichment, and record creation. | ||
| */ | ||
|
|
||
| import * as path from "node:path"; | ||
|
|
||
| import type { Kysely } from "kysely"; | ||
| import { ulid } from "ulidx"; | ||
|
|
||
| import { MediaRepository, type MediaItem } from "../../database/repositories/media.js"; | ||
| import type { Database } from "../../database/types.js"; | ||
| import { enrichImageMetadata } from "../../media/enrich.js"; | ||
| import { matchesMimeAllowlist, normalizeMime } from "../../media/mime.js"; | ||
| import { SsrfError, ssrfSafeFetch } from "../../security/ssrf.js"; | ||
| import type { Storage } from "../../storage/types.js"; | ||
| import { decodeBase64Bytes } from "../../utils/base64.js"; | ||
| import { computeContentHash } from "../../utils/hash.js"; | ||
| import { CONTENT_TYPE_RE, DEFAULT_MAX_UPLOAD_SIZE, formatFileSize } from "../schemas/media.js"; | ||
| import type { ApiResult } from "../types.js"; | ||
| import { GLOBAL_UPLOAD_ALLOWLIST } from "./media-allowlist.js"; | ||
|
|
||
| export interface MediaUploadInput { | ||
| /** Original filename (e.g. 'logo.png'); the extension is kept on the storage key. */ | ||
| filename: string; | ||
| /** Base64-encoded file contents. Exactly one of `base64` / `url` must be set. */ | ||
| base64?: string; | ||
| /** External http(s) URL to fetch the file from. Exactly one of `base64` / `url` must be set. */ | ||
| url?: string; | ||
| /** | ||
| * MIME type. Required with `base64`; optional with `url` (falls back to | ||
| * the response's Content-Type header). | ||
| */ | ||
| contentType?: string; | ||
| /** Alt text stored on the media record. */ | ||
| alt?: string; | ||
| authorId?: string; | ||
| /** Upload size limit in bytes (defaults to DEFAULT_MAX_UPLOAD_SIZE). */ | ||
| maxUploadSize?: number; | ||
| } | ||
|
|
||
| export type MediaUploadResult = ApiResult<{ | ||
| item: MediaItem & { url: string }; | ||
| deduplicated?: boolean; | ||
| }>; | ||
|
|
||
| function fail(code: string, message: string): MediaUploadResult { | ||
| return { success: false, error: { code, message } }; | ||
| } | ||
|
|
||
| /** Same relative-URL shape the REST media routes return. */ | ||
| function withUrl(item: MediaItem): MediaItem & { url: string } { | ||
| return { ...item, url: `/_emdash/api/media/file/${item.storageKey}` }; | ||
| } | ||
|
|
||
| /** Strip parameters from a Content-Type header value (e.g. '; charset=...'). */ | ||
| function bareMime(headerValue: string): string { | ||
| return (headerValue.split(";")[0] ?? "").trim(); | ||
| } | ||
|
|
||
| /** | ||
| * Acquire the file bytes and MIME type from either the base64 payload or | ||
| * the external URL. Returns an error result on any validation failure. | ||
| */ | ||
| async function acquireBytes( | ||
| input: MediaUploadInput, | ||
| maxUploadSize: number, | ||
| ): Promise<{ bytes: Uint8Array; mimeType: string } | MediaUploadResult> { | ||
| if (input.base64) { | ||
| if (!input.contentType) { | ||
| return fail("VALIDATION_ERROR", "contentType is required when uploading base64 data"); | ||
| } | ||
| // Cheap size precheck on the encoded string (decoded size is ~3/4 of | ||
| // the base64 length) before allocating the decoded buffer. | ||
| if ((input.base64.length * 3) / 4 > maxUploadSize) { | ||
| return fail( | ||
| "PAYLOAD_TOO_LARGE", | ||
| `File exceeds maximum size of ${formatFileSize(maxUploadSize)}`, | ||
| ); | ||
| } | ||
| try { | ||
| return { bytes: decodeBase64Bytes(input.base64), mimeType: input.contentType }; | ||
| } catch { | ||
| return fail("VALIDATION_ERROR", "Invalid base64 data"); | ||
| } | ||
| } | ||
|
|
||
| // url mode — the caller guarantees exactly one source, so url is set here | ||
| const url = input.url; | ||
| if (!url) { | ||
| return fail("VALIDATION_ERROR", "Provide exactly one of 'base64' or 'url'"); | ||
| } | ||
| let response: Response; | ||
| try { | ||
| response = await ssrfSafeFetch(url, { headers: { accept: "*/*" } }); | ||
| } catch (error) { | ||
| if (error instanceof SsrfError) { | ||
| return fail("VALIDATION_ERROR", `URL not allowed: ${error.message}`); | ||
| } | ||
| return fail("FETCH_ERROR", "Failed to fetch file from URL"); | ||
| } | ||
| if (!response.ok) { | ||
| return fail("FETCH_ERROR", `Failed to fetch file from URL (HTTP ${response.status})`); | ||
| } | ||
|
|
||
| const contentLength = response.headers.get("Content-Length"); | ||
| if (contentLength && parseInt(contentLength, 10) > maxUploadSize) { | ||
| return fail( | ||
| "PAYLOAD_TOO_LARGE", | ||
| `File exceeds maximum size of ${formatFileSize(maxUploadSize)}`, | ||
| ); | ||
| } | ||
|
|
||
| const mimeType = input.contentType ?? bareMime(response.headers.get("Content-Type") ?? ""); | ||
| if (!mimeType) { | ||
| return fail("VALIDATION_ERROR", "Could not determine MIME type — pass contentType explicitly"); | ||
| } | ||
|
|
||
| const bytes = new Uint8Array(await response.arrayBuffer()); | ||
| return { bytes, mimeType }; | ||
| } | ||
|
|
||
| /** | ||
| * Upload a media file from base64 data or an external URL. | ||
| * | ||
| * Mirrors the REST `POST /_emdash/api/media` route: global MIME allowlist, | ||
| * size limit, content-hash dedupe (returns the existing item with | ||
| * `deduplicated: true`), storage upload with cleanup on failure, and | ||
| * image metadata enrichment (dimensions, blurhash, dominant color). | ||
| */ | ||
| 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; | ||
|
|
||
| // Validate the raw MIME string before normalize/allowlist: normalizeMime | ||
| // only strips parameters and matchesMimeAllowlist only checks startsWith, | ||
| // so without this a crafted value like "image/png\r\nX-Evil: 1" would | ||
| // reach the storage backend's ContentType header and be echoed by the | ||
| // media file serving route. | ||
| if (!CONTENT_TYPE_RE.test(acquired.mimeType)) { | ||
| return fail("VALIDATION_ERROR", "Invalid content type"); | ||
| } | ||
| const mimeType = normalizeMime(acquired.mimeType); | ||
|
|
||
| if (!matchesMimeAllowlist(mimeType, GLOBAL_UPLOAD_ALLOWLIST)) { | ||
| return fail("INVALID_TYPE", "File type not allowed"); | ||
| } | ||
| if (bytes.byteLength > rawMax) { | ||
| return fail("PAYLOAD_TOO_LARGE", `File exceeds maximum size of ${formatFileSize(rawMax)}`); | ||
| } | ||
|
|
||
| try { | ||
| const contentHash = await computeContentHash(bytes); | ||
| const repo = new MediaRepository(db); | ||
|
|
||
| const existing = await repo.findByContentHash(contentHash); | ||
| if (existing) { | ||
| return { success: true, data: { item: withUrl(existing), deduplicated: true } }; | ||
| } | ||
|
|
||
| const storageKey = `${ulid()}${path.extname(input.filename)}`; | ||
| await storage.upload({ key: storageKey, body: bytes, contentType: mimeType }); | ||
|
|
||
| try { | ||
| const enriched = await enrichImageMetadata(bytes, mimeType); | ||
| const item = await repo.create({ | ||
| filename: input.filename, | ||
| mimeType, | ||
| size: bytes.byteLength, | ||
| width: enriched.width, | ||
| height: enriched.height, | ||
| alt: input.alt, | ||
| storageKey, | ||
| contentHash, | ||
| blurhash: enriched.blurhash, | ||
| dominantColor: enriched.dominantColor, | ||
| authorId: input.authorId, | ||
| }); | ||
| return { success: true, data: { item: withUrl(item) } }; | ||
| } catch (error) { | ||
| // Don't leave an orphaned object in storage when record creation fails | ||
| try { | ||
| await storage.delete(storageKey); | ||
| } catch { | ||
| // Ignore cleanup errors | ||
| } | ||
| throw error; | ||
| } | ||
| } catch { | ||
| return fail("UPLOAD_ERROR", "Upload failed"); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { z } from "zod"; | |||||||||||||||||||
| import { | ||||||||||||||||||||
| bylineCreateBody, | ||||||||||||||||||||
| bylineUpdateBody, | ||||||||||||||||||||
| CONTENT_TYPE_RE, | ||||||||||||||||||||
| contentBylineInputSchema, | ||||||||||||||||||||
| contentSeoInput, | ||||||||||||||||||||
| } from "#api/schemas.js"; | ||||||||||||||||||||
|
|
@@ -1824,9 +1825,8 @@ export function createMcpServer(): McpServer { | |||||||||||||||||||
| "caller is responsible for placing the file at `storageKey` (typically " + | ||||||||||||||||||||
| "using a signed upload URL obtained from the admin UI or a separate API). " + | ||||||||||||||||||||
| "This tool persists the metadata record so the file is discoverable via " + | ||||||||||||||||||||
| "media_list / media_get and can be referenced by content. For binary " + | ||||||||||||||||||||
| "uploads the MCP transport is not appropriate — use the signed-upload " + | ||||||||||||||||||||
| "flow instead.", | ||||||||||||||||||||
| "media_list / media_get and can be referenced by content. To upload the " + | ||||||||||||||||||||
| "file itself, use media_upload (base64 data or a public URL) instead.", | ||||||||||||||||||||
| inputSchema: z.object({ | ||||||||||||||||||||
| filename: z.string().describe("Original filename (e.g. 'logo.png')"), | ||||||||||||||||||||
| mimeType: z.string().describe("MIME type (e.g. 'image/png')"), | ||||||||||||||||||||
|
|
@@ -1863,6 +1863,70 @@ export function createMcpServer(): McpServer { | |||||||||||||||||||
| }, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| server.registerTool( | ||||||||||||||||||||
| "media_upload", | ||||||||||||||||||||
| { | ||||||||||||||||||||
| title: "Upload Media", | ||||||||||||||||||||
| description: | ||||||||||||||||||||
| "Upload a media file from base64-encoded data or an external URL and " + | ||||||||||||||||||||
| "register it in the media library. Returns the media item with id, " + | ||||||||||||||||||||
| "storageKey, and url — ready to reference from content fields (e.g. " + | ||||||||||||||||||||
| "featured_image) via content_create / content_update. Uploads are " + | ||||||||||||||||||||
| "deduplicated by content hash: re-uploading identical bytes returns " + | ||||||||||||||||||||
| "the existing item with deduplicated: true. URL fetches must resolve " + | ||||||||||||||||||||
| "to a public http(s) host (SSRF-guarded). Subject to the global " + | ||||||||||||||||||||
| "upload MIME allowlist and the configured maximum upload size.", | ||||||||||||||||||||
| inputSchema: z.object({ | ||||||||||||||||||||
| filename: z.string().min(1).describe("Filename including extension (e.g. 'cover.png')"), | ||||||||||||||||||||
| base64: z | ||||||||||||||||||||
| .string() | ||||||||||||||||||||
| .optional() | ||||||||||||||||||||
| .describe("Base64-encoded file contents. Provide exactly one of base64 / url."), | ||||||||||||||||||||
| url: z | ||||||||||||||||||||
| .string() | ||||||||||||||||||||
| .url() | ||||||||||||||||||||
| .optional() | ||||||||||||||||||||
| .describe( | ||||||||||||||||||||
| "Public http(s) URL to fetch the file from. Provide exactly one of base64 / url.", | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| contentType: z | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Suggested change
(You may also want
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in f91f8c7 — |
||||||||||||||||||||
| .string() | ||||||||||||||||||||
| .regex(CONTENT_TYPE_RE, "Invalid content type") | ||||||||||||||||||||
| .optional() | ||||||||||||||||||||
| .describe( | ||||||||||||||||||||
| "MIME type (e.g. 'image/png'). Required with base64; with url it " + | ||||||||||||||||||||
| "defaults to the response's Content-Type header.", | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| alt: z.string().optional().describe("Alt text for accessibility"), | ||||||||||||||||||||
| }), | ||||||||||||||||||||
| annotations: { destructiveHint: false }, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| async (args, extra) => { | ||||||||||||||||||||
| requireScope(extra, "media:write"); | ||||||||||||||||||||
| requireRole(extra, Role.CONTRIBUTOR); | ||||||||||||||||||||
| const { emdash, userId } = getExtra(extra); | ||||||||||||||||||||
| if (!emdash.storage) { | ||||||||||||||||||||
| return respondError("NO_STORAGE", "Storage not configured"); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| try { | ||||||||||||||||||||
| const { handleMediaUpload } = await import("../api/handlers/media-upload.js"); | ||||||||||||||||||||
| return unwrap( | ||||||||||||||||||||
| await handleMediaUpload(emdash.db, emdash.storage, { | ||||||||||||||||||||
| filename: args.filename, | ||||||||||||||||||||
| base64: args.base64, | ||||||||||||||||||||
| url: args.url, | ||||||||||||||||||||
| contentType: args.contentType, | ||||||||||||||||||||
| alt: args.alt, | ||||||||||||||||||||
| authorId: userId, | ||||||||||||||||||||
| maxUploadSize: emdash.config.maxUploadSize, | ||||||||||||||||||||
| }), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||
| return respondHandlerError(error, "UPLOAD_ERROR"); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| server.registerTool( | ||||||||||||||||||||
| "media_get", | ||||||||||||||||||||
| { | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[needs fixing] The
mimeTypefrom base64 input and from the remoteContent-Typeheader is normalized and allow-listed without validating the raw string first.normalizeMimeonly strips the parameter suffix and trims leading/trailing whitespace, andmatchesMimeAllowlistonly checksstartsWith, so a value such asimage/png\r\nX-Evil: 1passes the allowlist and is later passed straight tostorage.upload(...). That value reaches S3/R2ContentTypeand is echoed byGET /_emdash/api/media/file/:key.Re-use the existing
CONTENT_TYPE_REfromapi/schemas/media.ts(export it if necessary) and reject malformed values before the allowlist/storage check:There was a problem hiding this comment.
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_REfromapi/schemas/media.tsand the handler now rejects the raw MIME string before normalize/allowlist, exactly as suggested. Added two regression tests: a crafted base64contentType(image/png\r\nX-Evil: 1) and a malformed remoteContent-Typeheader both fail withVALIDATION_ERRORand never reach storage.