feat(mcp): upload_trip_file tool for adding documents to a trip (#1566)

This commit is contained in:
Maurice
2026-10-01 12:39:58 +02:00
parent 1c34a74498
commit 88afdb986c
8 changed files with 228 additions and 23 deletions
+14
View File
@@ -47,3 +47,17 @@ export const BLOCKED_EXTENSIONS = [
// the extra '..' keeps the same absolute <server>/uploads/files under both the
// src (vitest) and dist (runtime) layouts.
export const filesDir = path.join(__dirname, '../../../uploads/files');
/**
* Whether a trip-file upload named `originalname` passes the extension rules:
* never a blocked extension or an SVG, otherwise on the operator's list (or `*`),
* and video regardless of that list (#823). Shared by the multipart filter and
* the MCP upload tool so the two ingestion paths cannot drift apart.
*/
export function isUploadTypeAllowed(originalname: string, mimetype: string, allowedList: string): boolean {
const ext = path.extname(originalname).toLowerCase();
if (BLOCKED_EXTENSIONS.includes(ext) || mimetype.includes('svg')) return false;
const allowed = allowedList.split(',').map((e) => e.trim().toLowerCase());
const fileExt = ext.replace('.', '');
return allowed.includes(fileExt) || isVideoExtension(fileExt) || allowed.includes('*');
}
+6 -14
View File
@@ -29,7 +29,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import type { TripAccess } from '../database/database.service';
import { Trip } from '../permissions/trip.decorator';
import { MAX_FILE_SIZE, BLOCKED_EXTENSIONS, isVideoExtension } from './files.constants';
import { MAX_FILE_SIZE, isUploadTypeAllowed, isVideoExtension } from './files.constants';
import { FileUploadDto, FileUpdateDto, FileLinkDto } from './files.dto';
import { AllowedFileTypesService } from './allowed-file-types.service';
@@ -44,19 +44,11 @@ import { AllowedFileTypesService } from './allowed-file-types.service';
*/
export function filesUploadFileFilter(allowedTypes: AllowedFileTypesService): Options['fileFilter'] {
return (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const reject = () => {
// i18n key — the client resolves it via t() (see translateApiError).
const err: Error & { statusCode?: number } = new Error('files.uploadErrorType');
err.statusCode = 400;
cb(err);
};
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase());
const fileExt = ext.replace('.', '');
// Video is accepted as media regardless of the admin doc-types allowlist (#823).
if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
reject();
if (isUploadTypeAllowed(file.originalname, file.mimetype, allowedTypes.get())) return cb(null, true);
// i18n key — the client resolves it via t() (see translateApiError).
const err: Error & { statusCode?: number } = new Error('files.uploadErrorType');
err.statusCode = 400;
cb(err);
};
}
+62 -3
View File
@@ -10,9 +10,21 @@ import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { AuthService } from '../auth/auth.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { FilesService, FileContentError, FILE_CONTENT_MAX } from './files.service';
import { AllowedFileTypesService } from './allowed-file-types.service';
import { MAX_FILE_SIZE, isUploadTypeAllowed } from './files.constants';
import { contentTypeFor } from '../storage/content-type';
const CONTENT_MAX_MB = Math.round(FILE_CONTENT_MAX / (1024 * 1024));
/**
* The upload tool's cap: the same 10 MB a read hands back, or the operator's
* upload limit when that is lower. The bytes travel base64 inside one JSON-RPC
* message, so the multipart route's 500 MB video allowance has no place here.
*/
const UPLOAD_MAX = Math.min(FILE_CONTENT_MAX, MAX_FILE_SIZE);
const UPLOAD_MAX_MB = Math.max(1, Math.floor(UPLOAD_MAX / (1024 * 1024)));
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
/**
* Bytes worth handing over as text. Everything else goes back base64: a model
* can pass those on to a converter, but it cannot read them, and decoding a PDF
@@ -42,9 +54,10 @@ function contentRefusal(err: FileContentError): string {
* two listing tools carry no file_* right because the routes they mirror carry
* none either; trip access is the whole gate there.
*
* There is no upload tool on purpose. MCP has no file transport, and an upload
* faked through base64 would be a second, unpoliced ingestion path beside the
* multipart route with its extension filter and per-type size caps.
* upload_trip_file (#1566) takes the bytes base64-encoded, since MCP has no file
* transport. It is policed like the multipart route: the same extension rules
* through isUploadTypeAllowed, the file_upload right, the demo block and the
* same-trip check on what it attaches to, plus a tighter size cap.
*/
@McpController()
export class FilesMcp {
@@ -52,6 +65,7 @@ export class FilesMcp {
private readonly files: FilesService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
private readonly allowedTypes: AllowedFileTypesService,
) {}
@Tool({
@@ -103,6 +117,51 @@ export class FilesMcp {
}
}
@Tool({
name: 'upload_trip_file',
description: `Add a document to a trip, e.g. a booking confirmation PDF or a ticket, and optionally attach it to a booking or a place in the same call. Pass the bytes base64-encoded in content, up to ${UPLOAD_MAX_MB} MB decoded. The file name has to carry an extension the trip's file manager accepts; the type is derived from it. Returns the new file, whose ID works with read_trip_file and link_trip_file.`,
inputSchema: {
tripId: z.number().int().positive(),
filename: z.string().trim().min(1).max(255).describe('File name including its extension, e.g. "hotel-confirmation.pdf"'),
content: z.string().min(1).max(Math.ceil(UPLOAD_MAX / 3) * 4).describe('The file bytes, base64-encoded'),
description: z.string().max(1000).optional().describe('Free-text description'),
reservation_id: z.number().int().positive().optional().describe('Booking on the same trip to attach the file to'),
place_id: z.number().int().positive().optional().describe('Place on the same trip to attach the file to'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'files', mode: 'write' },
})
async uploadTripFile(
{ tripId, filename, content, description, reservation_id, place_id }: {
tripId: number; filename: string; content: string; description?: string; reservation_id?: number; place_id?: number;
},
ctx: McpContext,
) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.guards.hasTripPermission('file_upload', tripId, ctx.userId)) return permissionDenied();
// A path someone pasted in is not a name; keep only the last segment.
const originalname = filename.split(/[\\/]/).pop()!.trim();
const mimetype = contentTypeFor(originalname);
if (!originalname || !isUploadTypeAllowed(originalname, mimetype, this.allowedTypes.get())) {
return errorResult('This file type is not allowed on this TREK instance.');
}
const clean = content.replace(/\s+/g, '');
if (!BASE64.test(clean)) return errorResult('content is not valid base64.');
const bytes = Buffer.from(clean, 'base64');
if (bytes.length === 0) return errorResult('The file is empty.');
if (bytes.length > UPLOAD_MAX) return errorResult(`File is too large (over ${UPLOAD_MAX_MB} MB). Ask the user to upload it in TREK instead.`);
const foreign = this.files.findForeignLinkTarget(tripId, { reservation_id, place_id });
if (foreign) return errorResult(`Linked item does not belong to this trip (${foreign}).`);
const file = await this.files.createFileFromBytes(tripId, { originalname, mimetype, bytes }, ctx.userId, {
reservation_id: reservation_id ?? null,
place_id: place_id ?? null,
description: description ?? null,
});
this.guards.safeBroadcast(tripId, 'file:created', { file });
return ok({ file });
}
@Tool({
name: 'update_trip_file',
description: 'Set a file\'s description and attach it to a booking or a place. Fields left out keep their current value, null detaches. place_id and reservation_id are the file\'s primary attachment, the one the file manager shows next to it. To attach one document to several bookings or places at once, use link_trip_file instead.',
+3 -1
View File
@@ -40,7 +40,9 @@ import { MAX_FILE_SIZE, MAX_VIDEO_SIZE } from './files.constants';
// AuthModule + McpSharedModule feed FilesMcp's demo and RBAC guards. Neither is
// @Global, and AuthModule reaches this domain only through the leaf
// AllowedFileTypesModule, so importing it here stays cycle-free.
EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AuthModule, McpSharedModule],
EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AuthModule, McpSharedModule,
// FilesMcp's upload tool checks the same extension list as the multipart filter.
AllowedFileTypesModule],
controllers: [FilesController, FilesDownloadController],
providers: [FilesService, FilesRpc, FilesMcp],
exports: [FilesService],
+18 -1
View File
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import path from 'path';
import type { Readable } from 'node:stream';
import { randomUUID } from 'crypto';
import { Readable } from 'node:stream';
import type { Request } from 'express';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
@@ -282,6 +283,22 @@ export class FilesService {
return formatFile(created);
}
/**
* Store bytes that arrived without multipart (the MCP upload tool, #1566) and
* record them like a multipart upload: same random storage key, same row. The
* caller has already run the type and size checks.
*/
async createFileFromBytes(
tripId: string | number,
upload: { originalname: string; mimetype: string; bytes: Buffer },
uploadedBy: number,
opts: { place_id?: number | null; reservation_id?: number | null; description?: string | null },
) {
const filename = `${randomUUID()}${path.extname(upload.originalname)}`;
await this.storage.put('files', filename, Readable.from([upload.bytes]), { contentType: upload.mimetype });
return this.createFile(tripId, { filename, originalname: upload.originalname, size: upload.bytes.length, mimetype: upload.mimetype }, uploadedBy, opts);
}
updateFile(
id: string | number,
current: TripFile,