diff --git a/apps/mcp/src/widget/views/Upload.tsx b/apps/mcp/src/widget/views/Upload.tsx index ed71c856a..6e34490b2 100644 --- a/apps/mcp/src/widget/views/Upload.tsx +++ b/apps/mcp/src/widget/views/Upload.tsx @@ -29,15 +29,53 @@ function formatFileSize(bytes: number): string { const ACCEPT = ".txt,.pdf,.png,.jpg,.jpeg,.mp4" +// The MCP transport rejects JSON-RPC bodies over 4 MiB with a bare 413 +// (MAXIMUM_MESSAGE_SIZE_BYTES in the agents SDK). The file travels base64 +// encoded (~4/3 inflation) inside that body, so cap the raw file size such +// that the encoded payload plus the JSON-RPC envelope stays under the limit. +const TRANSPORT_MESSAGE_LIMIT_BYTES = 4 * 1024 * 1024 +const ENVELOPE_ALLOWANCE_BYTES = 64 * 1024 +const MAX_UPLOAD_BYTES = Math.floor( + ((TRANSPORT_MESSAGE_LIMIT_BYTES - ENVELOPE_ALLOWANCE_BYTES) * 3) / 4, +) + +function fileTooLargeMessage(size: number): string { + return `This file is ${formatFileSize(size)}. The maximum upload size is ${formatFileSize(MAX_UPLOAD_BYTES)} — please choose a smaller file.` +} + +// The transport-level 413 surfaces as an opaque error string; translate it +// so the user sees the size limit instead of a generic failure. +function friendlyUploadError(raw: string, fileSize: number): string { + if (/413|too large|payload/i.test(raw)) { + return fileTooLargeMessage(fileSize) + } + return raw +} + export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) { const { callTool } = useApp() const log = useLog() const [file, setFile] = useState(null) + const [fileError, setFileError] = useState(null) const [selectedTag, setSelectedTag] = useState( activeTag ?? writableTags[0] ?? null, ) const [uploading, setUploading] = useState(false) + function handleFileSelect(selected: File) { + if (selected.size > MAX_UPLOAD_BYTES) { + log( + "warning", + `[upload] rejected oversized file: ${selected.name} (${selected.size}B > ${MAX_UPLOAD_BYTES}B)`, + ) + setFileError(fileTooLargeMessage(selected.size)) + setFile(null) + return + } + setFileError(null) + setFile(selected) + } + const options = useMemo( () => writableTags.map((tag) => ({ value: tag, label: tag })), [writableTags], @@ -59,13 +97,17 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) { }) if (!result.ok || !result.data) { log("error", `[upload] failed: ${result.error}`) - onError(result.error ?? "Upload failed") + onError( + result.error + ? friendlyUploadError(result.error, file.size) + : "Upload failed", + ) return } onAdvance(result.data) } catch (err) { log("error", `[upload] threw: ${err}`) - onError(String(err)) + onError(friendlyUploadError(String(err), file.size)) } finally { setUploading(false) } @@ -102,11 +144,21 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) { /> ) : ( - + + + {fileError ? ( +

+ {fileError} +

+ ) : null} +
)} {writableTags.length > 0 ? (