Skip to content
Open
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
66 changes: 59 additions & 7 deletions apps/mcp/src/widget/views/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<File | null>(null)
const [fileError, setFileError] = useState<string | null>(null)
const [selectedTag, setSelectedTag] = useState<string | null>(
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],
Expand All @@ -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)
}
Expand Down Expand Up @@ -102,11 +144,21 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
/>
</div>
) : (
<FileUpload
accept={ACCEPT}
description="Supports TXT, PDF, PNG, JPG, MP4"
onFile={setFile}
/>
<Stack gap="sm">
<FileUpload
accept={ACCEPT}
description={`Supports TXT, PDF, PNG, JPG, MP4 · up to ${formatFileSize(MAX_UPLOAD_BYTES)}`}
onFile={handleFileSelect}
/>
{fileError ? (
<p
className="text-(length:--text-xs) leading-relaxed text-error"
role="alert"
>
{fileError}
</p>
) : null}
</Stack>
)}

{writableTags.length > 0 ? (
Expand Down
Loading