Skip to content

Commit 83412de

Browse files
committed
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.
1 parent 35badc9 commit 83412de

8 files changed

Lines changed: 305 additions & 4 deletions

File tree

docs/features/mcp-connectors.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,15 @@ executeAiTool(...) / live editor bridge
128128
| `server.ts` / `registry.ts` | Low-level SDK server, TypeBox input schemas, catalog deduplication, and capability filtering. |
129129
| `editorBridge.ts` | Per-user, per-scope live workspace bridge. |
130130
| `tools/publishTool.ts` | Explicit canonical full-site publish with MCP audit metadata. |
131+
| `tools/uploadMediaTool.ts` | Server-resolved image upload (`content_upload_media`) — inline base64 or SSRF-guarded `sourceUrl` download, through the shared media pipeline. |
131132

132133
## Tool execution model
133134

134135
MCP exposes the full deduplicated tool catalog, filtered by the connection's capabilities.
135136

136-
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.
137+
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.
138+
139+
`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`.
137140

138141
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.
139142

server/ai/mcp/registry.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ describe('mcp registry', () => {
6969
expect(tools.some((t) => t.name === 'site_insert_html')).toBe(false)
7070
})
7171

72+
it('exposes content_upload_media only with both write and media.write capabilities', () => {
73+
const upload = mcpToolsForCapabilities(FULL).find((t) => t.name === 'content_upload_media')
74+
expect(upload).toBeTruthy()
75+
expect(upload!.execution).toBe('server') // in-process, no editor needed
76+
expect(upload!.mutates).toBe(true)
77+
// Gated by media.write…
78+
expect(mcpToolsForCapabilities(FULL.filter((c) => c !== 'media.write')).map((t) => t.name))
79+
.not.toContain('content_upload_media')
80+
// …and by ai.tools.write (it mutates).
81+
expect(mcpToolsForCapabilities(FULL.filter((c) => c !== 'ai.tools.write')).map((t) => t.name))
82+
.not.toContain('content_upload_media')
83+
})
84+
7285
it('only exposes full-site publish when both write and publish capabilities are granted', () => {
7386
expect(mcpToolsForCapabilities(FULL).map((t) => t.name)).toContain('site_publish')
7487
expect(mcpToolsForCapabilities(FULL.filter((c) => c !== 'pages.publish')).map((t) => t.name))

server/ai/mcp/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { styleMcpTools } from './tools/styleTools'
3131
import { contextMcpTools } from './tools/contextTool'
3232
import { documentMcpTools } from './tools/documentTools'
3333
import { createPublishMcpTool, type McpPublishRuntime } from './tools/publishTool'
34+
import { uploadMediaMcpTool } from './tools/uploadMediaTool'
3435

3536
// Server-resolved site read tools whose handlers read the browser-posted
3637
// `ctx.snapshot`, which is null over MCP — they'd return nothing or throw.
@@ -52,6 +53,7 @@ function allMcpTools(runtime?: McpPublishRuntime): AiTool[] {
5253
...styleMcpTools,
5354
...documentMcpTools,
5455
createPublishMcpTool(runtime),
56+
uploadMediaMcpTool,
5557
...contentTools,
5658
...siteTools,
5759
]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { uploadMediaMcpTool } from './uploadMediaTool'
3+
import type { ToolContext } from '../../runtime/types'
4+
5+
// The handler validates input, the source selector, and the SSRF guard BEFORE
6+
// it ever touches `ctx.db` or the storage layer, so these paths need no real
7+
// db/network/DNS. IP-literal hosts short-circuit DNS resolution, letting us
8+
// exercise the blocklist deterministically.
9+
const ctx = {
10+
db: {} as never,
11+
userId: 'user-1',
12+
capabilities: [],
13+
scope: 'content',
14+
} as unknown as ToolContext
15+
16+
function upload(input: Record<string, unknown>): Promise<unknown> {
17+
return uploadMediaMcpTool.handler!(input, ctx)
18+
}
19+
20+
describe('content_upload_media', () => {
21+
it('requires exactly one of data / sourceUrl', async () => {
22+
await expect(upload({ filename: 'x.png' })).rejects.toThrow(/exactly one/i)
23+
await expect(
24+
upload({ filename: 'x.png', data: 'AAAA', sourceUrl: 'https://example.com/a.png' }),
25+
).rejects.toThrow(/exactly one/i)
26+
})
27+
28+
it('rejects a non-https sourceUrl', async () => {
29+
await expect(
30+
upload({ filename: 'x.png', sourceUrl: 'http://example.com/a.png' }),
31+
).rejects.toThrow(/https/i)
32+
})
33+
34+
it('refuses SSRF targets in blocked ranges', async () => {
35+
for (const host of ['127.0.0.1', '169.254.169.254', '10.0.0.5', '[::1]']) {
36+
await expect(
37+
upload({ filename: 'x.png', sourceUrl: `https://${host}/a.png` }),
38+
).rejects.toThrow(/blocked address/i)
39+
}
40+
})
41+
42+
it('rejects inline bytes that are not a supported image', async () => {
43+
// "AAAA" decodes to 3 zero bytes — no magic-byte signature matches, so the
44+
// shared upload core rejects it before any storage/db work.
45+
await expect(upload({ filename: 'x.png', data: 'AAAA' })).rejects.toThrow(/JPEG|PNG|image/i)
46+
})
47+
})
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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+
}

server/ai/tools/content/readTools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ const listMediaTool: AiTool = {
335335
execution: 'server',
336336
requiredCapabilities: ['media.read'],
337337
description:
338-
"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.",
338+
"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.",
339339
inputSchema: ListMediaInput,
340340
handler: async (input, ctx) => {
341341
const args = input as Static<typeof ListMediaInput>

server/handlers/cms/media.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { CMS_API_PREFIX } from './shared'
5454
import { runRouteTable, type Route, type RouteParams } from './routeTable'
5555
import {
5656
EXTENSION_FOR_MIME,
57+
MAX_MEDIA_BYTES,
5758
acceptReplacementMedia,
5859
acceptUploadedMedia,
5960
readUploadedFile,
@@ -62,8 +63,6 @@ import { removeVariantFiles } from './mediaVariants'
6263
import { dispatchDelete } from './mediaUploadDispatch'
6364
import { materializeAssetListForClient } from '../../publish/mediaPresentation'
6465

65-
const MAX_MEDIA_BYTES = 50 * 1024 * 1024
66-
6766
const MEDIA_LIBRARY_MIMES = Object.keys(EXTENSION_FOR_MIME) as Array<
6867
keyof typeof EXTENSION_FOR_MIME
6968
>

server/handlers/cms/mediaUpload.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ export const EXTENSION_FOR_MIME = {
7272

7373
type AcceptedMediaMime = keyof typeof EXTENSION_FOR_MIME
7474

75+
/**
76+
* Hard ceiling on any single media upload, shared by every surface that
77+
* accepts bytes (the HTTP media route, the MCP upload tool). Callers pass it
78+
* as the `maxBytes` policy knob so the limit lives in exactly one place.
79+
*/
80+
export const MAX_MEDIA_BYTES = 50 * 1024 * 1024
81+
7582
export const IMAGE_MIMES: ReadonlyArray<AcceptedMediaMime> = [
7683
'image/jpeg',
7784
'image/png',

0 commit comments

Comments
 (0)