Skip to content
Open
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: 4 additions & 1 deletion docs/features/mcp-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions server/ai/mcp/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions server/ai/mcp/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -52,6 +53,7 @@ function allMcpTools(runtime?: McpPublishRuntime): AiTool[] {
...styleMcpTools,
...documentMcpTools,
createPublishMcpTool(runtime),
uploadMediaMcpTool,
...contentTools,
...siteTools,
]
Expand Down
47 changes: 47 additions & 0 deletions server/ai/mcp/tools/uploadMediaTool.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<unknown> {
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)
})
})
230 changes: 230 additions & 0 deletions server/ai/mcp/tools/uploadMediaTool.ts
Original file line number Diff line number Diff line change
@@ -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<typeof UploadMediaInput>

/** 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<URL> {
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<Uint8Array>,
maxBytes: number,
): Promise<Uint8Array> {
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<Uint8Array> {
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:<mime>;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,
}
},
}
2 changes: 1 addition & 1 deletion server/ai/tools/content/readTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ListMediaInput>
Expand Down
3 changes: 1 addition & 2 deletions server/handlers/cms/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
>
Expand Down
7 changes: 7 additions & 0 deletions server/handlers/cms/mediaUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcceptedMediaMime> = [
'image/jpeg',
'image/png',
Expand Down