From 83412ded95d514dcd2b4f6738d8081a0185128e5 Mon Sep 17 00:00:00 2001 From: Hector Velasquez Date: Thu, 30 Jul 2026 13:11:25 -0300 Subject: [PATCH] feat(mcp): add content_upload_media tool for image uploads Adds a server-resolved MCP tool that uploads an image into the Media library, closing the gap where connectors could list and assign media but never create it. Bytes arrive inline (base64) or via an https sourceUrl the host downloads under the plugin network layer's SSRF blocklist (https-only, DNS-resolved, per-hop redirect revalidation, size-capped). Reuses the shared acceptUploadedMedia core for magic-byte sniffing, SVG sanitisation, storage dispatch, and responsive variants. Hoists the 50MB limit to MAX_MEDIA_BYTES in the upload core so the HTTP route and the tool share one source of truth. --- docs/features/mcp-connectors.md | 5 +- server/ai/mcp/registry.test.ts | 13 ++ server/ai/mcp/registry.ts | 2 + server/ai/mcp/tools/uploadMediaTool.test.ts | 47 ++++ server/ai/mcp/tools/uploadMediaTool.ts | 230 ++++++++++++++++++++ server/ai/tools/content/readTools.ts | 2 +- server/handlers/cms/media.ts | 3 +- server/handlers/cms/mediaUpload.ts | 7 + 8 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 server/ai/mcp/tools/uploadMediaTool.test.ts create mode 100644 server/ai/mcp/tools/uploadMediaTool.ts diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 9beb570ec..dff9b0530 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -128,12 +128,15 @@ executeAiTool(...) / live editor bridge | `server.ts` / `registry.ts` | Low-level SDK server, TypeBox input schemas, catalog deduplication, and capability filtering. | | `editorBridge.ts` | Per-user, per-scope live workspace bridge. | | `tools/publishTool.ts` | Explicit canonical full-site publish with MCP audit metadata. | +| `tools/uploadMediaTool.ts` | Server-resolved image upload (`content_upload_media`) — inline base64 or SSRF-guarded `sourceUrl` download, through the shared media pipeline. | ## Tool execution model MCP exposes the full deduplicated tool catalog, filtered by the connection's capabilities. -Server-resolved tools work without an editor open. They include content reads, `get_context`, `site_list_documents`, `site_read_styles`, `site_list_breakpoints`, and explicit `site_publish`. Publishing requires `ai.tools.write` plus `pages.publish`, runs the canonical full-site pipeline, swaps the static slot atomically, and records the connection id in the publish audit event. +Server-resolved tools work without an editor open. They include content reads, `get_context`, `site_list_documents`, `site_read_styles`, `site_list_breakpoints`, `content_upload_media`, and explicit `site_publish`. Publishing requires `ai.tools.write` plus `pages.publish`, runs the canonical full-site pipeline, swaps the static slot atomically, and records the connection id in the publish audit event. + +`content_upload_media` is the one server-resolved write that mutates outside the live editor draft: it adds an image to the Media library through the same `acceptUploadedMedia` core the HTTP route uses (magic-byte sniffing, SVG sanitisation, storage dispatch, responsive variants). Bytes arrive inline (base64) or via an https `sourceUrl` the host downloads under the plugin network layer's SSRF blocklist — https-only, DNS-resolved, per-redirect-hop re-validation, size-capped. It requires `ai.tools.write` plus `media.write`. Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site or Content workspace. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. `tools/list` states that requirement in each browser tool's description, so a client learns the precondition when it picks the tool rather than from a failed call. diff --git a/server/ai/mcp/registry.test.ts b/server/ai/mcp/registry.test.ts index a441bc7fa..769abf4da 100644 --- a/server/ai/mcp/registry.test.ts +++ b/server/ai/mcp/registry.test.ts @@ -69,6 +69,19 @@ describe('mcp registry', () => { expect(tools.some((t) => t.name === 'site_insert_html')).toBe(false) }) + it('exposes content_upload_media only with both write and media.write capabilities', () => { + const upload = mcpToolsForCapabilities(FULL).find((t) => t.name === 'content_upload_media') + expect(upload).toBeTruthy() + expect(upload!.execution).toBe('server') // in-process, no editor needed + expect(upload!.mutates).toBe(true) + // Gated by media.write… + expect(mcpToolsForCapabilities(FULL.filter((c) => c !== 'media.write')).map((t) => t.name)) + .not.toContain('content_upload_media') + // …and by ai.tools.write (it mutates). + expect(mcpToolsForCapabilities(FULL.filter((c) => c !== 'ai.tools.write')).map((t) => t.name)) + .not.toContain('content_upload_media') + }) + it('only exposes full-site publish when both write and publish capabilities are granted', () => { expect(mcpToolsForCapabilities(FULL).map((t) => t.name)).toContain('site_publish') expect(mcpToolsForCapabilities(FULL.filter((c) => c !== 'pages.publish')).map((t) => t.name)) diff --git a/server/ai/mcp/registry.ts b/server/ai/mcp/registry.ts index 6baa09f85..3f8a011d0 100644 --- a/server/ai/mcp/registry.ts +++ b/server/ai/mcp/registry.ts @@ -31,6 +31,7 @@ import { styleMcpTools } from './tools/styleTools' import { contextMcpTools } from './tools/contextTool' import { documentMcpTools } from './tools/documentTools' import { createPublishMcpTool, type McpPublishRuntime } from './tools/publishTool' +import { uploadMediaMcpTool } from './tools/uploadMediaTool' // Server-resolved site read tools whose handlers read the browser-posted // `ctx.snapshot`, which is null over MCP — they'd return nothing or throw. @@ -52,6 +53,7 @@ function allMcpTools(runtime?: McpPublishRuntime): AiTool[] { ...styleMcpTools, ...documentMcpTools, createPublishMcpTool(runtime), + uploadMediaMcpTool, ...contentTools, ...siteTools, ] diff --git a/server/ai/mcp/tools/uploadMediaTool.test.ts b/server/ai/mcp/tools/uploadMediaTool.test.ts new file mode 100644 index 000000000..c49d0e782 --- /dev/null +++ b/server/ai/mcp/tools/uploadMediaTool.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test' +import { uploadMediaMcpTool } from './uploadMediaTool' +import type { ToolContext } from '../../runtime/types' + +// The handler validates input, the source selector, and the SSRF guard BEFORE +// it ever touches `ctx.db` or the storage layer, so these paths need no real +// db/network/DNS. IP-literal hosts short-circuit DNS resolution, letting us +// exercise the blocklist deterministically. +const ctx = { + db: {} as never, + userId: 'user-1', + capabilities: [], + scope: 'content', +} as unknown as ToolContext + +function upload(input: Record): Promise { + return uploadMediaMcpTool.handler!(input, ctx) +} + +describe('content_upload_media', () => { + it('requires exactly one of data / sourceUrl', async () => { + await expect(upload({ filename: 'x.png' })).rejects.toThrow(/exactly one/i) + await expect( + upload({ filename: 'x.png', data: 'AAAA', sourceUrl: 'https://example.com/a.png' }), + ).rejects.toThrow(/exactly one/i) + }) + + it('rejects a non-https sourceUrl', async () => { + await expect( + upload({ filename: 'x.png', sourceUrl: 'http://example.com/a.png' }), + ).rejects.toThrow(/https/i) + }) + + it('refuses SSRF targets in blocked ranges', async () => { + for (const host of ['127.0.0.1', '169.254.169.254', '10.0.0.5', '[::1]']) { + await expect( + upload({ filename: 'x.png', sourceUrl: `https://${host}/a.png` }), + ).rejects.toThrow(/blocked address/i) + } + }) + + it('rejects inline bytes that are not a supported image', async () => { + // "AAAA" decodes to 3 zero bytes — no magic-byte signature matches, so the + // shared upload core rejects it before any storage/db work. + await expect(upload({ filename: 'x.png', data: 'AAAA' })).rejects.toThrow(/JPEG|PNG|image/i) + }) +}) diff --git a/server/ai/mcp/tools/uploadMediaTool.ts b/server/ai/mcp/tools/uploadMediaTool.ts new file mode 100644 index 000000000..ffb93bca5 --- /dev/null +++ b/server/ai/mcp/tools/uploadMediaTool.ts @@ -0,0 +1,230 @@ +/** + * Media upload tool for MCP connectors. + * + * The rest of the MCP surface can *list* and *assign* existing media but never + * create it — an external agent building a page had no way to get an image into + * the library. This server-side tool closes that gap: it accepts image bytes + * either inline (base64) or by an https `sourceUrl` the host downloads, then + * runs them through the exact same `acceptUploadedMedia` core the HTTP media + * route uses (magic-byte MIME sniffing, SVG sanitisation, storage dispatch, + * responsive variants). No new byte-handling path is introduced. + * + * The `sourceUrl` branch is the sharp edge: letting the server fetch an + * arbitrary URL is a classic SSRF vector (`http://169.254.169.254/…` cloud + * metadata, `localhost` admin ports). It is gated exactly like the plugin + * network layer — https-only, DNS-resolved, every resolved address checked + * against the shared `isBlockedAddress` blocklist, redirects followed manually + * and re-validated per hop, and the download size-capped while streaming. + */ +import { isIP } from 'node:net' +import { lookup } from 'node:dns/promises' +import { Type } from '@core/utils/typeboxHelpers' +import type { Static } from '@core/utils/typeboxHelpers' +import type { AiTool, ToolContext } from '../../runtime/types' +import { + IMAGE_MIMES, + MAX_MEDIA_BYTES, + acceptUploadedMedia, +} from '../../../handlers/cms/mediaUpload' +import { updateMediaAssetMetadata } from '../../../repositories/media' +import { isBlockedAddress } from '../../../plugins/host/network' + +const MAX_IMAGE_REDIRECTS = 5 +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) +const FETCH_TIMEOUT_MS = 15_000 + +const UploadMediaInput = Type.Object( + { + filename: Type.String({ + minLength: 1, + description: + 'Display filename for the asset (e.g. "winner-1.webp"). The server picks the on-disk extension from the sniffed file type; the client extension is ignored.', + }), + data: Type.Optional( + Type.String({ + description: + 'Base64-encoded image bytes (a bare `data:` URI prefix is also accepted). Provide EITHER `data` OR `sourceUrl`, not both.', + }), + ), + sourceUrl: Type.Optional( + Type.String({ + description: + 'https URL the server downloads the image from (SSRF-guarded: private/loopback/link-local hosts are refused). Provide EITHER `data` OR `sourceUrl`, not both.', + }), + ), + altText: Type.Optional( + Type.String({ + description: 'Accessible alt text stored on the media asset.', + }), + ), + }, + { additionalProperties: false }, +) + +type UploadMediaArgs = Static + +/** Strip an IPv6 URL bracket wrapper so `isIP`/`isBlockedAddress` see the raw address. */ +function unbracketHost(host: string): string { + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host +} + +/** + * Validate one outbound target: https only, host resolves, and NO resolved + * address is in a blocked range. Re-run for every redirect hop so an + * allowed-looking host can never bounce the download to an internal target. + * + * Residual note: like the plugin path, we validate resolved addresses then + * `fetch` by hostname (which re-resolves) — the same DNS-rebinding window the + * rest of the host tolerates. Redirects are followed manually so each new + * location is re-validated here before any request is made to it. + */ +async function assertPublicHttpsTarget(urlString: string): Promise { + let parsed: URL + try { + parsed = new URL(urlString) + } catch { + throw new Error(`Invalid sourceUrl: "${urlString}"`) + } + if (parsed.protocol !== 'https:') { + throw new Error(`sourceUrl must be an https URL (got "${parsed.protocol}").`) + } + const host = unbracketHost(parsed.hostname) + const addresses = isIP(host) + ? [host] + : (await lookup(host, { all: true })).map((r) => r.address) + if (addresses.length === 0) { + throw new Error(`sourceUrl host "${host}" did not resolve to any address.`) + } + for (const address of addresses) { + if (isBlockedAddress(address)) { + throw new Error( + `sourceUrl host "${host}" resolves to a blocked address (${address}).`, + ) + } + } + return parsed +} + +/** Read a response stream into a buffer, aborting if it exceeds `maxBytes`. */ +async function readBounded( + stream: ReadableStream, + maxBytes: number, +): Promise { + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (!value) continue + total += value.length + if (total > maxBytes) { + await reader.cancel() + throw new Error(`Image exceeds the ${Math.floor(maxBytes / (1024 * 1024))} MB limit.`) + } + chunks.push(value) + } + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + +async function downloadRemoteImage(sourceUrl: string): Promise { + let current = sourceUrl + for (let hop = 0; ; hop++) { + const target = await assertPublicHttpsTarget(current) + const response = await fetch(target, { + redirect: 'manual', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + const location = response.headers.get('location') + if (REDIRECT_STATUSES.has(response.status) && location) { + if (hop >= MAX_IMAGE_REDIRECTS) { + throw new Error(`sourceUrl exceeded ${MAX_IMAGE_REDIRECTS} redirects.`) + } + current = new URL(location, current).toString() + continue + } + if (!response.ok || !response.body) { + throw new Error(`sourceUrl download failed (HTTP ${response.status}).`) + } + return readBounded(response.body, MAX_MEDIA_BYTES) + } +} + +/** Decode inline image bytes, tolerating a leading `data:;base64,` prefix. */ +function decodeInlineImage(data: string): Uint8Array { + const comma = data.startsWith('data:') ? data.indexOf(',') : -1 + const base64 = comma >= 0 ? data.slice(comma + 1) : data + return new Uint8Array(Buffer.from(base64, 'base64')) +} + +export const uploadMediaMcpTool: AiTool = { + name: 'content_upload_media', + description: + 'Upload a new image into the Media library and return its id + publicPath so you can reference or assign it. Provide the bytes as base64 in `data`, OR an https `sourceUrl` the server downloads. Accepts JPEG, PNG, GIF, WebP, and SVG (SVG is sanitised); the file type is sniffed from the bytes, not the filename. Requires the connector to have media.write.', + scope: 'content', + execution: 'server', + mutates: true, + requiredCapabilities: ['media.write'], + inputSchema: UploadMediaInput, + handler: async (input, ctx: ToolContext) => { + const args = input as UploadMediaArgs + const hasData = typeof args.data === 'string' && args.data.length > 0 + const hasUrl = typeof args.sourceUrl === 'string' && args.sourceUrl.length > 0 + if (hasData === hasUrl) { + throw new Error('Provide exactly one of `data` (base64) or `sourceUrl`.') + } + + const bytes = hasData + ? decodeInlineImage(args.data!) + : await downloadRemoteImage(args.sourceUrl!) + if (bytes.length === 0) { + throw new Error('Decoded image is empty.') + } + + const file = new File([bytes], args.filename) + const result = await acceptUploadedMedia(ctx.db, { + file, + maxBytes: MAX_MEDIA_BYTES, + allowedMimes: IMAGE_MIMES, + role: 'original', + uploadedByUserId: ctx.userId, + oversizedMessage: 'Image exceeds the 50 MB hard limit', + unsupportedMessage: + 'Only JPEG, PNG, GIF, WebP, and SVG images can be uploaded through this tool', + }) + // `acceptUploadedMedia` returns a ready-to-send error Response on any policy + // failure. MCP has no HTTP surface, so surface the envelope message as a + // thrown tool error instead. + if (result instanceof Response) { + const message = await result + .json() + .then((body: { error?: string }) => body.error) + .catch(() => null) + throw new Error(message ?? `Upload rejected (HTTP ${result.status}).`) + } + + let asset = result + if (args.altText !== undefined) { + const updated = await updateMediaAssetMetadata(ctx.db, asset.id, { + altText: args.altText, + }) + if (updated) asset = updated + } + + return { + id: asset.id, + filename: asset.filename, + publicPath: asset.publicPath, + mimeType: asset.mimeType, + altText: asset.altText, + width: asset.width, + height: asset.height, + } + }, +} diff --git a/server/ai/tools/content/readTools.ts b/server/ai/tools/content/readTools.ts index 54e56fb54..fc926efda 100644 --- a/server/ai/tools/content/readTools.ts +++ b/server/ai/tools/content/readTools.ts @@ -335,7 +335,7 @@ const listMediaTool: AiTool = { execution: 'server', requiredCapabilities: ['media.read'], description: - "List existing media assets so you can pick one for a media-typed field. Returns id, filename, publicPath, mimeType, altText, width, height. Optional `query` substring-matches filename + altText (case-insensitive); `mimeType` substring-matches the mime (e.g. 'image' to filter to images). `limit` default 25, max 100. You CANNOT upload new media — only assign existing.", + "List existing media assets so you can pick one for a media-typed field. Returns id, filename, publicPath, mimeType, altText, width, height. Optional `query` substring-matches filename + altText (case-insensitive); `mimeType` substring-matches the mime (e.g. 'image' to filter to images). `limit` default 25, max 100. To add a new image, use content_upload_media.", inputSchema: ListMediaInput, handler: async (input, ctx) => { const args = input as Static diff --git a/server/handlers/cms/media.ts b/server/handlers/cms/media.ts index 261e0bafd..8357109b8 100644 --- a/server/handlers/cms/media.ts +++ b/server/handlers/cms/media.ts @@ -54,6 +54,7 @@ import { CMS_API_PREFIX } from './shared' import { runRouteTable, type Route, type RouteParams } from './routeTable' import { EXTENSION_FOR_MIME, + MAX_MEDIA_BYTES, acceptReplacementMedia, acceptUploadedMedia, readUploadedFile, @@ -62,8 +63,6 @@ import { removeVariantFiles } from './mediaVariants' import { dispatchDelete } from './mediaUploadDispatch' import { materializeAssetListForClient } from '../../publish/mediaPresentation' -const MAX_MEDIA_BYTES = 50 * 1024 * 1024 - const MEDIA_LIBRARY_MIMES = Object.keys(EXTENSION_FOR_MIME) as Array< keyof typeof EXTENSION_FOR_MIME > diff --git a/server/handlers/cms/mediaUpload.ts b/server/handlers/cms/mediaUpload.ts index 8bdfe2f72..daa94d91e 100644 --- a/server/handlers/cms/mediaUpload.ts +++ b/server/handlers/cms/mediaUpload.ts @@ -72,6 +72,13 @@ export const EXTENSION_FOR_MIME = { type AcceptedMediaMime = keyof typeof EXTENSION_FOR_MIME +/** + * Hard ceiling on any single media upload, shared by every surface that + * accepts bytes (the HTTP media route, the MCP upload tool). Callers pass it + * as the `maxBytes` policy knob so the limit lives in exactly one place. + */ +export const MAX_MEDIA_BYTES = 50 * 1024 * 1024 + export const IMAGE_MIMES: ReadonlyArray = [ 'image/jpeg', 'image/png',