Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-media-upload.md
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.
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/ai-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Once connected, you can ask the AI assistant to perform any of these operations

- **Inspect** -- "What's the current site title?" or "Show me the social links"
- **Update identity** -- "Set the site title to 'Acme Blog' and tagline to 'Stories from the team'"
- **Set logo / favicon** -- "Use this image as the site logo" (after registering it with `media_create`)
- **Set logo / favicon** -- "Use this image as the site logo" (after uploading it with `media_upload`)
- **SEO defaults** -- "Set the default OG image to the new banner" or "Update the title separator to a vertical bar"
- **Social handles** -- "Add our Mastodon and YouTube links to the social settings"

Expand Down
27 changes: 24 additions & 3 deletions docs/src/content/docs/reference/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ In addition to scopes, some tools require a minimum RBAC role. Both must be sati
| Menus manage | Editor (40) |
| Settings read | Editor (40) |
| Settings manage | Admin (50) |
| Media upload (`media_create`) | Author (30) |
| Media upload (`media_upload`) | Contributor (20) |
| Media register (`media_create`) | Author (30) |
| Media usage repair | Admin (50) |

See the [Authentication guide](/guides/authentication#user-roles) for role definitions.
Expand All @@ -80,7 +81,7 @@ Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) forma

## Tools

The server exposes 52 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure.
The server exposes tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure.

### Content Tools

Expand Down Expand Up @@ -379,12 +380,32 @@ List uploaded media files with optional MIME type filtering and pagination.

**Scope:** `media:read` | **Read-only:** Yes

#### `media_upload`

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`. Image uploads are enriched automatically with dimensions, a blurhash placeholder, and the dominant color.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `filename` | `string` | Yes | Filename including extension (e.g. `cover.png`) |
| `base64` | `string` | One of `base64` / `url` | Base64-encoded file contents |
| `url` | `string` | One of `base64` / `url` | Public http(s) URL to fetch the file from |
| `contentType` | `string` | With `base64` | MIME type (e.g. `image/png`). With `url` it defaults to the response's `Content-Type` header. |
| `alt` | `string` | No | Alt text for accessibility |

<Aside>
URL fetches are SSRF-guarded: the URL must resolve to a public host, and redirects are re-validated. Uploads are subject to the global MIME allowlist (images, video, audio, PDF) and the configured maximum upload size.
</Aside>

**Scope:** `media:write` | **Minimum role:** Contributor

#### `media_create`

Register a media file that has already been uploaded to storage. The caller is responsible for placing the file at `storageKey` (typically using a signed upload URL 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.

<Aside>
The MCP transport is not appropriate for binary uploads. Use the signed-upload flow to put the bytes in storage, then call `media_create` to register the record.
To upload the file itself, use `media_upload` (base64 data or a public URL) instead.
</Aside>

| Parameter | Type | Required | Description |
Expand Down
210 changes: 210 additions & 0 deletions packages/core/src/api/handlers/media-upload.ts
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)) {

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.

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");
}
}
2 changes: 1 addition & 1 deletion packages/core/src/api/schemas/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function formatFileSize(bytes: number): string {

// Matches a full MIME type (type/subtype) with an optional semicolon-delimited
// parameter section. Forbids CR/LF to prevent header injection.
const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i;
export const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i;

export function mediaUploadUrlBody(maxSize: number) {
if (!Number.isFinite(maxSize) || maxSize <= 0) {
Expand Down
70 changes: 67 additions & 3 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { z } from "zod";
import {
bylineCreateBody,
bylineUpdateBody,
CONTENT_TYPE_RE,
contentBylineInputSchema,
contentSeoInput,
} from "#api/schemas.js";
Expand Down Expand Up @@ -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')"),
Expand Down Expand Up @@ -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

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.

.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",
{
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/utils/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,16 @@ export function encodeBase64(str: string): string {

/** Decode a standard base64 string to a UTF-8 string. */
export function decodeBase64(base64: string): string {
if (hasNative) return new TextDecoder().decode(Uint8Array.fromBase64(base64));
return new TextDecoder().decode(decodeBase64Bytes(base64));
}

/** Decode a standard base64 string to raw bytes (for binary payloads). */
export function decodeBase64Bytes(base64: string): Uint8Array {
if (hasNative) return Uint8Array.fromBase64(base64);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder().decode(bytes);
return bytes;
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading