|
| 1 | +/** |
| 2 | + * Media upload tool for MCP connectors. |
| 3 | + * |
| 4 | + * The rest of the MCP surface can *list* and *assign* existing media but never |
| 5 | + * create it — an external agent building a page had no way to get an image into |
| 6 | + * the library. This server-side tool closes that gap: it accepts image bytes |
| 7 | + * either inline (base64) or by an https `sourceUrl` the host downloads, then |
| 8 | + * runs them through the exact same `acceptUploadedMedia` core the HTTP media |
| 9 | + * route uses (magic-byte MIME sniffing, SVG sanitisation, storage dispatch, |
| 10 | + * responsive variants). No new byte-handling path is introduced. |
| 11 | + * |
| 12 | + * The `sourceUrl` branch is the sharp edge: letting the server fetch an |
| 13 | + * arbitrary URL is a classic SSRF vector (`http://169.254.169.254/…` cloud |
| 14 | + * metadata, `localhost` admin ports). It is gated exactly like the plugin |
| 15 | + * network layer — https-only, DNS-resolved, every resolved address checked |
| 16 | + * against the shared `isBlockedAddress` blocklist, redirects followed manually |
| 17 | + * and re-validated per hop, and the download size-capped while streaming. |
| 18 | + */ |
| 19 | +import { isIP } from 'node:net' |
| 20 | +import { lookup } from 'node:dns/promises' |
| 21 | +import { Type } from '@core/utils/typeboxHelpers' |
| 22 | +import type { Static } from '@core/utils/typeboxHelpers' |
| 23 | +import type { AiTool, ToolContext } from '../../runtime/types' |
| 24 | +import { |
| 25 | + IMAGE_MIMES, |
| 26 | + MAX_MEDIA_BYTES, |
| 27 | + acceptUploadedMedia, |
| 28 | +} from '../../../handlers/cms/mediaUpload' |
| 29 | +import { updateMediaAssetMetadata } from '../../../repositories/media' |
| 30 | +import { isBlockedAddress } from '../../../plugins/host/network' |
| 31 | + |
| 32 | +const MAX_IMAGE_REDIRECTS = 5 |
| 33 | +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) |
| 34 | +const FETCH_TIMEOUT_MS = 15_000 |
| 35 | + |
| 36 | +const UploadMediaInput = Type.Object( |
| 37 | + { |
| 38 | + filename: Type.String({ |
| 39 | + minLength: 1, |
| 40 | + description: |
| 41 | + '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.', |
| 42 | + }), |
| 43 | + data: Type.Optional( |
| 44 | + Type.String({ |
| 45 | + description: |
| 46 | + 'Base64-encoded image bytes (a bare `data:` URI prefix is also accepted). Provide EITHER `data` OR `sourceUrl`, not both.', |
| 47 | + }), |
| 48 | + ), |
| 49 | + sourceUrl: Type.Optional( |
| 50 | + Type.String({ |
| 51 | + description: |
| 52 | + 'https URL the server downloads the image from (SSRF-guarded: private/loopback/link-local hosts are refused). Provide EITHER `data` OR `sourceUrl`, not both.', |
| 53 | + }), |
| 54 | + ), |
| 55 | + altText: Type.Optional( |
| 56 | + Type.String({ |
| 57 | + description: 'Accessible alt text stored on the media asset.', |
| 58 | + }), |
| 59 | + ), |
| 60 | + }, |
| 61 | + { additionalProperties: false }, |
| 62 | +) |
| 63 | + |
| 64 | +type UploadMediaArgs = Static<typeof UploadMediaInput> |
| 65 | + |
| 66 | +/** Strip an IPv6 URL bracket wrapper so `isIP`/`isBlockedAddress` see the raw address. */ |
| 67 | +function unbracketHost(host: string): string { |
| 68 | + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host |
| 69 | +} |
| 70 | + |
| 71 | +/** |
| 72 | + * Validate one outbound target: https only, host resolves, and NO resolved |
| 73 | + * address is in a blocked range. Re-run for every redirect hop so an |
| 74 | + * allowed-looking host can never bounce the download to an internal target. |
| 75 | + * |
| 76 | + * Residual note: like the plugin path, we validate resolved addresses then |
| 77 | + * `fetch` by hostname (which re-resolves) — the same DNS-rebinding window the |
| 78 | + * rest of the host tolerates. Redirects are followed manually so each new |
| 79 | + * location is re-validated here before any request is made to it. |
| 80 | + */ |
| 81 | +async function assertPublicHttpsTarget(urlString: string): Promise<URL> { |
| 82 | + let parsed: URL |
| 83 | + try { |
| 84 | + parsed = new URL(urlString) |
| 85 | + } catch { |
| 86 | + throw new Error(`Invalid sourceUrl: "${urlString}"`) |
| 87 | + } |
| 88 | + if (parsed.protocol !== 'https:') { |
| 89 | + throw new Error(`sourceUrl must be an https URL (got "${parsed.protocol}").`) |
| 90 | + } |
| 91 | + const host = unbracketHost(parsed.hostname) |
| 92 | + const addresses = isIP(host) |
| 93 | + ? [host] |
| 94 | + : (await lookup(host, { all: true })).map((r) => r.address) |
| 95 | + if (addresses.length === 0) { |
| 96 | + throw new Error(`sourceUrl host "${host}" did not resolve to any address.`) |
| 97 | + } |
| 98 | + for (const address of addresses) { |
| 99 | + if (isBlockedAddress(address)) { |
| 100 | + throw new Error( |
| 101 | + `sourceUrl host "${host}" resolves to a blocked address (${address}).`, |
| 102 | + ) |
| 103 | + } |
| 104 | + } |
| 105 | + return parsed |
| 106 | +} |
| 107 | + |
| 108 | +/** Read a response stream into a buffer, aborting if it exceeds `maxBytes`. */ |
| 109 | +async function readBounded( |
| 110 | + stream: ReadableStream<Uint8Array>, |
| 111 | + maxBytes: number, |
| 112 | +): Promise<Uint8Array> { |
| 113 | + const reader = stream.getReader() |
| 114 | + const chunks: Uint8Array[] = [] |
| 115 | + let total = 0 |
| 116 | + for (;;) { |
| 117 | + const { done, value } = await reader.read() |
| 118 | + if (done) break |
| 119 | + if (!value) continue |
| 120 | + total += value.length |
| 121 | + if (total > maxBytes) { |
| 122 | + await reader.cancel() |
| 123 | + throw new Error(`Image exceeds the ${Math.floor(maxBytes / (1024 * 1024))} MB limit.`) |
| 124 | + } |
| 125 | + chunks.push(value) |
| 126 | + } |
| 127 | + const out = new Uint8Array(total) |
| 128 | + let offset = 0 |
| 129 | + for (const chunk of chunks) { |
| 130 | + out.set(chunk, offset) |
| 131 | + offset += chunk.length |
| 132 | + } |
| 133 | + return out |
| 134 | +} |
| 135 | + |
| 136 | +async function downloadRemoteImage(sourceUrl: string): Promise<Uint8Array> { |
| 137 | + let current = sourceUrl |
| 138 | + for (let hop = 0; ; hop++) { |
| 139 | + const target = await assertPublicHttpsTarget(current) |
| 140 | + const response = await fetch(target, { |
| 141 | + redirect: 'manual', |
| 142 | + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), |
| 143 | + }) |
| 144 | + const location = response.headers.get('location') |
| 145 | + if (REDIRECT_STATUSES.has(response.status) && location) { |
| 146 | + if (hop >= MAX_IMAGE_REDIRECTS) { |
| 147 | + throw new Error(`sourceUrl exceeded ${MAX_IMAGE_REDIRECTS} redirects.`) |
| 148 | + } |
| 149 | + current = new URL(location, current).toString() |
| 150 | + continue |
| 151 | + } |
| 152 | + if (!response.ok || !response.body) { |
| 153 | + throw new Error(`sourceUrl download failed (HTTP ${response.status}).`) |
| 154 | + } |
| 155 | + return readBounded(response.body, MAX_MEDIA_BYTES) |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +/** Decode inline image bytes, tolerating a leading `data:<mime>;base64,` prefix. */ |
| 160 | +function decodeInlineImage(data: string): Uint8Array { |
| 161 | + const comma = data.startsWith('data:') ? data.indexOf(',') : -1 |
| 162 | + const base64 = comma >= 0 ? data.slice(comma + 1) : data |
| 163 | + return new Uint8Array(Buffer.from(base64, 'base64')) |
| 164 | +} |
| 165 | + |
| 166 | +export const uploadMediaMcpTool: AiTool = { |
| 167 | + name: 'content_upload_media', |
| 168 | + description: |
| 169 | + '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.', |
| 170 | + scope: 'content', |
| 171 | + execution: 'server', |
| 172 | + mutates: true, |
| 173 | + requiredCapabilities: ['media.write'], |
| 174 | + inputSchema: UploadMediaInput, |
| 175 | + handler: async (input, ctx: ToolContext) => { |
| 176 | + const args = input as UploadMediaArgs |
| 177 | + const hasData = typeof args.data === 'string' && args.data.length > 0 |
| 178 | + const hasUrl = typeof args.sourceUrl === 'string' && args.sourceUrl.length > 0 |
| 179 | + if (hasData === hasUrl) { |
| 180 | + throw new Error('Provide exactly one of `data` (base64) or `sourceUrl`.') |
| 181 | + } |
| 182 | + |
| 183 | + const bytes = hasData |
| 184 | + ? decodeInlineImage(args.data!) |
| 185 | + : await downloadRemoteImage(args.sourceUrl!) |
| 186 | + if (bytes.length === 0) { |
| 187 | + throw new Error('Decoded image is empty.') |
| 188 | + } |
| 189 | + |
| 190 | + const file = new File([bytes], args.filename) |
| 191 | + const result = await acceptUploadedMedia(ctx.db, { |
| 192 | + file, |
| 193 | + maxBytes: MAX_MEDIA_BYTES, |
| 194 | + allowedMimes: IMAGE_MIMES, |
| 195 | + role: 'original', |
| 196 | + uploadedByUserId: ctx.userId, |
| 197 | + oversizedMessage: 'Image exceeds the 50 MB hard limit', |
| 198 | + unsupportedMessage: |
| 199 | + 'Only JPEG, PNG, GIF, WebP, and SVG images can be uploaded through this tool', |
| 200 | + }) |
| 201 | + // `acceptUploadedMedia` returns a ready-to-send error Response on any policy |
| 202 | + // failure. MCP has no HTTP surface, so surface the envelope message as a |
| 203 | + // thrown tool error instead. |
| 204 | + if (result instanceof Response) { |
| 205 | + const message = await result |
| 206 | + .json() |
| 207 | + .then((body: { error?: string }) => body.error) |
| 208 | + .catch(() => null) |
| 209 | + throw new Error(message ?? `Upload rejected (HTTP ${result.status}).`) |
| 210 | + } |
| 211 | + |
| 212 | + let asset = result |
| 213 | + if (args.altText !== undefined) { |
| 214 | + const updated = await updateMediaAssetMetadata(ctx.db, asset.id, { |
| 215 | + altText: args.altText, |
| 216 | + }) |
| 217 | + if (updated) asset = updated |
| 218 | + } |
| 219 | + |
| 220 | + return { |
| 221 | + id: asset.id, |
| 222 | + filename: asset.filename, |
| 223 | + publicPath: asset.publicPath, |
| 224 | + mimeType: asset.mimeType, |
| 225 | + altText: asset.altText, |
| 226 | + width: asset.width, |
| 227 | + height: asset.height, |
| 228 | + } |
| 229 | + }, |
| 230 | +} |
0 commit comments