mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(mcp): upload_trip_file tool for adding documents to a trip (#1566)
This commit is contained in:
@@ -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('*');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user