Validate MCP file uploads in execution UI

This commit is contained in:
Lucio Lelii 2026-09-11 14:50:21 +02:00
parent 86e61690bb
commit 81f2a890f2
6 changed files with 166 additions and 2 deletions

View File

@ -205,12 +205,20 @@ export type FlowValueKind = {
multiple: boolean;
};
/** Client-facing hints for a file input. The server validates the actual bytes again. */
export type FileInputConstraints = {
acceptedMediaTypes?: string[];
maxFiles?: number | null;
maxTotalBytes?: number | null;
};
export type FlowPort = {
name: string;
type: string;
multiple: boolean;
valueKinds?: FlowValueKind[];
valueSchema?: Record<string, unknown> | null;
fileConstraints?: FileInputConstraints | null;
};
export type FlowBlockConnection = {

View File

@ -1,4 +1,12 @@
import { FlowBlockConnection, FlowData, FlowNode, FlowNodeDependency, FlowPort, LLMDescriptor } from './flow';
import {
FileInputConstraints,
FlowBlockConnection,
FlowData,
FlowNode,
FlowNodeDependency,
FlowPort,
LLMDescriptor
} from './flow';
import { BiasExecutionContext } from './bias-impact';
export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUSPENDED' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
@ -151,6 +159,7 @@ export type TaskExecutionGlobalInputDescriptor = {
description?: string | null;
cleanupPolicy?: string | null;
multiple?: boolean;
fileConstraints?: FileInputConstraints | null;
};
export type TaskExecutionStep = {

View File

@ -207,9 +207,13 @@
<input
type="file"
class="block w-full text-sm text-slate-700"
[attr.accept]="fileAccept(executionInput)"
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
[disabled]="readOnly()"
(change)="onFileInputChange(executionInput, $event)" />
@if (fileInputHint(executionInput); as hint) {
<div class="mt-1 text-[11px] text-slate-500">{{ hint }}</div>
}
} @else if (isMultipleInput(executionInput)) {
@if (itemsOpen(executionInput)) {
<div class="inputs-panel-items">

View File

@ -1,7 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { EditableExecutionInput, TaskExecutionInputsPanelComponent, parseJsonArrayInput } from './task-execution-inputs-panel';
import {
EditableExecutionInput,
TaskExecutionInputsPanelComponent,
parseJsonArrayInput,
validateFileSelection
} from './task-execution-inputs-panel';
function makeInput(overrides: Partial<EditableExecutionInput> = {}): EditableExecutionInput {
const input: EditableExecutionInput = {
@ -50,6 +55,55 @@ async function build(inputs: EditableExecutionInput[], options: {
describe('TaskExecutionInputsPanelComponent', () => {
afterEach(() => TestBed.resetTestingModule());
it('validates MCP image constraints before upload', () => {
const imageInput = makeInput({
type: 'FILE',
multiple: true,
fileConstraints: {
acceptedMediaTypes: ['image/png', 'image/jpeg', 'image/webp'],
maxFiles: 4,
maxTotalBytes: 15_000_000
}
});
const png = new File(['image'], 'reference.png', { type: 'image/png' });
const gif = new File(['image'], 'reference.gif', { type: 'image/gif' });
expect(validateFileSelection(imageInput, [png])).toBeNull();
expect(validateFileSelection(imageInput, [png, png, png, png, png])).toContain('At most 4 files');
expect(validateFileSelection(imageInput, [gif])).toContain('PNG, JPEG, WebP');
});
it('validates MCP PDF aggregate budget before upload', () => {
const documentInput = makeInput({
type: 'FILE',
multiple: true,
fileConstraints: {
acceptedMediaTypes: ['application/pdf'],
maxFiles: 4,
maxTotalBytes: 32_000_000
}
});
const tooLarge = new File([new Uint8Array(32_000_001)], 'specification.pdf', { type: 'application/pdf' });
expect(validateFileSelection(documentInput, [tooLarge])).toContain('32,000,000 bytes');
});
it('renders the MIME filter and server-provided upload hint', async () => {
const fixture = await build([makeInput({
type: 'FILE',
multiple: true,
fileConstraints: {
acceptedMediaTypes: ['application/pdf'],
maxFiles: 4,
maxTotalBytes: 32_000_000
}
})]);
const picker = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement;
expect(picker.accept).toBe('application/pdf');
expect(fixture.nativeElement.textContent).toContain('Allowed: PDF; up to 4 files; total up to 32,000,000 bytes.');
});
it('counts every input, node ones included, since all of them are required', async () => {
// A step without its manual input never reaches READY, so a node input blocks the start just
// as a global one does and must be part of the tally.

View File

@ -8,6 +8,7 @@ import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TaskExecutionAuthorizationRequirement } from '@models/task-execution';
import { FileInputConstraints } from '@models/flow';
export type JsonArrayParseResult =
| { values: string[]; error: null }
@ -72,6 +73,7 @@ export type EditableExecutionInput = {
subtitle: string;
type: string;
multiple: boolean;
fileConstraints?: FileInputConstraints | null;
value: string | string[];
/**
* Whether a value is actually stored in the execution - unsaved edits do not count. Node inputs
@ -80,6 +82,68 @@ export type EditableExecutionInput = {
provided: boolean;
};
const FILE_EXTENSION_BY_MEDIA_TYPE: Record<string, string[]> = {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/webp': ['.webp'],
'application/pdf': ['.pdf']
};
function formatBytes(bytes: number): string {
return `${new Intl.NumberFormat('en-US').format(bytes)} bytes`;
}
function mediaTypeLabel(mediaTypes: string[]): string {
const labels = mediaTypes.map((mediaType) => {
if (mediaType === 'image/png') return 'PNG';
if (mediaType === 'image/jpeg') return 'JPEG';
if (mediaType === 'image/webp') return 'WebP';
if (mediaType === 'application/pdf') return 'PDF';
return mediaType;
});
return labels.join(', ');
}
function inferredMediaType(file: File, acceptedMediaTypes: string[]): string | null {
const browserType = file.type.trim().toLowerCase();
if (acceptedMediaTypes.includes(browserType)) return browserType;
const lowerName = file.name.toLowerCase();
return acceptedMediaTypes.find((mediaType) =>
(FILE_EXTENSION_BY_MEDIA_TYPE[mediaType] ?? []).some((extension) => lowerName.endsWith(extension))) ?? null;
}
/**
* Validates an upload before it crosses the network. This mirrors the public constraints on the
* descriptor; the MCP executor still validates byte signatures and totals authoritatively.
*/
export function validateFileSelection(input: EditableExecutionInput, files: File[]): string | null {
if (!files.length) return 'Choose at least one file.';
if (!input.multiple && files.length !== 1) return 'This input accepts one file.';
const constraints = input.fileConstraints;
if (!constraints) return null;
const maxFiles = input.multiple
? constraints.maxFiles ?? Number.MAX_SAFE_INTEGER
: 1;
if (files.length > maxFiles) {
return `At most ${maxFiles} ${maxFiles === 1 ? 'file is' : 'files are'} allowed.`;
}
const acceptedMediaTypes = constraints.acceptedMediaTypes ?? [];
for (const file of files) {
if (file.size === 0) return `${file.name} is empty.`;
if (acceptedMediaTypes.length && !inferredMediaType(file, acceptedMediaTypes)) {
return `${file.name} must be ${mediaTypeLabel(acceptedMediaTypes)}.`;
}
}
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
if (constraints.maxTotalBytes != null && totalBytes > constraints.maxTotalBytes) {
return `The selected files total ${formatBytes(totalBytes)}; the limit is ${formatBytes(constraints.maxTotalBytes)}.`;
}
return null;
}
@Component({
selector: 'app-task-execution-inputs-panel',
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule, ModalShellComponent],
@ -309,6 +373,22 @@ export class TaskExecutionInputsPanelComponent {
return input.multiple;
}
fileAccept(input: EditableExecutionInput): string | null {
const mediaTypes = input.fileConstraints?.acceptedMediaTypes ?? [];
return mediaTypes.length ? mediaTypes.join(',') : null;
}
fileInputHint(input: EditableExecutionInput): string | null {
const constraints = input.fileConstraints;
if (!constraints) return null;
const parts: string[] = [];
const mediaTypes = constraints.acceptedMediaTypes ?? [];
if (mediaTypes.length) parts.push(`Allowed: ${mediaTypeLabel(mediaTypes)}`);
if (constraints.maxFiles != null) parts.push(`up to ${constraints.maxFiles} files`);
if (constraints.maxTotalBytes != null) parts.push(`total up to ${formatBytes(constraints.maxTotalBytes)}`);
return parts.length ? `${parts.join('; ')}.` : null;
}
textValues(input: EditableExecutionInput): string[] {
if (Array.isArray(input.value)) {
return input.value;

View File

@ -32,6 +32,7 @@ import { ExecutionVaultCredential, LlmProviderCapability } from '@models/llm-pro
import { VaultSecret } from '@models/assistant';
import {
EditableExecutionInput,
validateFileSelection,
InputCopySource,
TaskExecutionInputsPanelComponent
} from '@shared/task-execution-inputs-panel/task-execution-inputs-panel';
@ -814,6 +815,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
subtitle: inputName,
type: String(descriptor?.kind ?? 'TEXT').toUpperCase(),
multiple: Boolean(descriptor?.multiple),
fileConstraints: descriptor?.fileConstraints ?? null,
// The backend's own answer, and it reports what is stored - so an unsaved edit does not
// make an input look satisfied.
provided: !missingGlobalInputNames.has(inputName),
@ -843,6 +845,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
subtitle: inputName,
type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(),
multiple: Boolean(input.descriptor?.multiple),
fileConstraints: input.descriptor?.fileConstraints ?? null,
// No per-input flag from the backend here, so judge the stored value, ignoring pending.
provided: hasStoredValue(rawValue),
value: pendingValue ?? normalizeEditableInputValue(rawValue, Boolean(input.descriptor?.multiple))
@ -1265,6 +1268,12 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const executionId = this.execution()?.id;
if (!executionId || !files.length) return;
const validationError = validateFileSelection(input, files);
if (validationError) {
this.setInputError(input.key, validationError);
return;
}
this.setInputSaving(input.key, true);
const request$ = input.scope === 'global'
? (