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',