From dc9347133226a1a918bd74d224a1b229fa459295 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 10 Aug 2026 14:13:59 +0200 Subject: [PATCH 01/39] feat(datasource-toolkit): expose a shared data uri codec The data uri codec used for action File fields was duplicated in the agent and in plugin-aws-s3. Expose it here so both, and agent-client, share one implementation. Empty media types are now filtered out, which the agent copy did not do (it emitted charset=undefined). Co-Authored-By: Claude Opus 5 (1M context) --- packages/datasource-toolkit/src/index.ts | 1 + .../datasource-toolkit/src/utils/data-uri.ts | 39 +++++++ .../test/utils/data-uri.test.ts | 104 ++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 packages/datasource-toolkit/src/utils/data-uri.ts create mode 100644 packages/datasource-toolkit/test/utils/data-uri.test.ts diff --git a/packages/datasource-toolkit/src/index.ts b/packages/datasource-toolkit/src/index.ts index 5a60731a29..97048ff3e8 100644 --- a/packages/datasource-toolkit/src/index.ts +++ b/packages/datasource-toolkit/src/index.ts @@ -61,4 +61,5 @@ export { default as TypeGetter } from './validation/type-getter'; export { default as CollectionUtils } from './utils/collection'; export { default as RecordUtils } from './utils/record'; export { default as SchemaUtils } from './utils/schema'; +export { isDataUri, makeDataUri, parseDataUri } from './utils/data-uri'; export { default as Deferred } from './deferred'; diff --git a/packages/datasource-toolkit/src/utils/data-uri.ts b/packages/datasource-toolkit/src/utils/data-uri.ts new file mode 100644 index 0000000000..92c5210014 --- /dev/null +++ b/packages/datasource-toolkit/src/utils/data-uri.ts @@ -0,0 +1,39 @@ +import type { File } from '../interfaces/action'; + +export function isDataUri(value: unknown): value is string { + return typeof value === 'string' && value.startsWith('data:'); +} + +// Hand-rolled rather than a spec-compliant parser: Forest carries the filename in a +// non-standard `name=` media type that RFC 2397 does not define, and spec parsers drop it. +export function makeDataUri(file: File): string { + if (!file) return null; + + const { mimeType, buffer, ...mediaTypes } = file; + const encoded = Object.entries(mediaTypes) + .filter(([, value]) => value) + .map(([key, value]) => `${key}=${encodeURIComponent(value as string)}`) + .join(';'); + + const header = encoded ? `data:${mimeType};${encoded}` : `data:${mimeType}`; + + return `${header};base64,${buffer.toString('base64')}`; +} + +export function parseDataUri(dataUri: string): File { + if (!dataUri) return null; + + const [header, data] = dataUri.substring(5).split(','); + const [mimeType, ...mediaTypes] = header.split(';'); + const result = { mimeType, buffer: Buffer.from(data, 'base64') }; + + for (const mediaType of mediaTypes) { + const index = mediaType.indexOf('='); + + if (index !== -1) { + result[mediaType.substring(0, index)] = decodeURIComponent(mediaType.substring(index + 1)); + } + } + + return result as File; +} diff --git a/packages/datasource-toolkit/test/utils/data-uri.test.ts b/packages/datasource-toolkit/test/utils/data-uri.test.ts new file mode 100644 index 0000000000..5d24f153a0 --- /dev/null +++ b/packages/datasource-toolkit/test/utils/data-uri.test.ts @@ -0,0 +1,104 @@ +import { isDataUri, makeDataUri, parseDataUri } from '../../src/utils/data-uri'; + +describe('DataUri', () => { + describe('isDataUri', () => { + it.each([ + ['data:text/plain;base64,aGk=', true], + ['https://example.com/file.pdf', false], + ['$uploadedFile:token', false], + [42, false], + [null, false], + [undefined, false], + ])('answers %p for %p', (value, expected) => { + expect(isDataUri(value)).toBe(expected); + }); + }); + + describe('makeDataUri', () => { + it('encodes a file without media types', () => { + const uri = makeDataUri({ mimeType: 'text/plain', buffer: Buffer.from('hi'), name: '' }); + + expect(uri).toBe('data:text/plain;base64,aGk='); + }); + + it('percent-encodes the name so it cannot break the header parsing', () => { + const uri = makeDataUri({ + mimeType: 'application/pdf', + buffer: Buffer.from('%PDF'), + name: 'rapport final;v2,def.pdf', + }); + + expect(uri).toBe( + `data:application/pdf;name=rapport%20final%3Bv2%2Cdef.pdf;base64,${Buffer.from( + '%PDF', + ).toString('base64')}`, + ); + }); + + it('encodes the charset when it is set', () => { + const uri = makeDataUri({ + mimeType: 'text/csv', + buffer: Buffer.from('a,b'), + name: 'data.csv', + charset: 'utf-8', + }); + + expect(uri).toBe( + `data:text/csv;name=data.csv;charset=utf-8;base64,${Buffer.from('a,b').toString('base64')}`, + ); + }); + + it('omits media types that are empty instead of emitting them as undefined', () => { + const uri = makeDataUri({ + mimeType: 'text/plain', + buffer: Buffer.from('hi'), + name: 'note.txt', + charset: undefined, + }); + + expect(uri).toBe('data:text/plain;name=note.txt;base64,aGk='); + expect(uri).not.toContain('charset'); + }); + + it('returns null when there is no file', () => { + expect(makeDataUri(null)).toBeNull(); + }); + }); + + describe('parseDataUri', () => { + it('decodes the mime type and the buffer', () => { + const file = parseDataUri('data:text/plain;base64,aGk='); + + expect(file.mimeType).toBe('text/plain'); + expect(file.buffer.toString()).toBe('hi'); + }); + + it('percent-decodes the media types', () => { + const file = parseDataUri('data:text/plain;name=rapport%20final.txt;base64,aGk='); + + expect(file.name).toBe('rapport final.txt'); + }); + + it('returns null when there is no data uri', () => { + expect(parseDataUri(null)).toBeNull(); + }); + }); + + describe('round trip', () => { + it.each([ + 'simple.pdf', + 'rapport final.txt', + 'facture;2026,janvier.pdf', + 'reçu-café.png', + 'a=b&c.txt', + ])('preserves the name %p', name => { + const file = { mimeType: 'application/octet-stream', buffer: Buffer.from([1, 2, 3]), name }; + + const parsed = parseDataUri(makeDataUri(file)); + + expect(parsed.name).toBe(name); + expect(parsed.buffer).toEqual(file.buffer); + expect(parsed.mimeType).toBe(file.mimeType); + }); + }); +}); From 5ace7c88c25f79c4fe0f575c3c114004373b7f40 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 10 Aug 2026 14:18:01 +0200 Subject: [PATCH 02/39] feat(agent-client): encode file values for action File fields setFields was a pass-through, so every caller had to hand-craft the data uri the agent expects for a File field. It now accepts a File object ({ buffer, mimeType, name }) and encodes it, while strings pass through untouched so already encoded data uris and opaque references still work. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/action-fields/action-field.ts | 3 +- .../src/action-fields/field-form-states.ts | 3 +- .../src/action-fields/field-getter.ts | 2 +- .../src/action-fields/file-value.ts | 49 +++++ .../agent-client/src/action-fields/types.ts | 3 +- packages/agent-client/src/index.ts | 1 + .../test/action-fields/file-value.test.ts | 169 ++++++++++++++++++ 7 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 packages/agent-client/src/action-fields/file-value.ts create mode 100644 packages/agent-client/test/action-fields/file-value.test.ts diff --git a/packages/agent-client/src/action-fields/action-field.ts b/packages/agent-client/src/action-fields/action-field.ts index cd5e13cfba..b93efe3006 100644 --- a/packages/agent-client/src/action-fields/action-field.ts +++ b/packages/agent-client/src/action-fields/action-field.ts @@ -1,4 +1,5 @@ import type FieldFormStates from './field-form-states'; +import type { PlainField } from './types'; export default abstract class ActionField { private readonly fieldsFormStates: FieldFormStates; @@ -17,7 +18,7 @@ export default abstract class ActionField { return this.name; } - getType(): string { + getType(): PlainField['type'] { return this.field?.getType(); } diff --git a/packages/agent-client/src/action-fields/field-form-states.ts b/packages/agent-client/src/action-fields/field-form-states.ts index 1f68f3307a..c011ef4f5f 100644 --- a/packages/agent-client/src/action-fields/field-form-states.ts +++ b/packages/agent-client/src/action-fields/field-form-states.ts @@ -7,6 +7,7 @@ import type { import HttpRequester from '../http-requester'; import ActionFieldMultipleChoice from './action-field-multiple-choice'; import FieldGetter from './field-getter'; +import encodeFileFieldValue from './file-value'; export default class FieldFormStates { private readonly fields: FieldGetter[]; @@ -70,7 +71,7 @@ export default class FieldFormStates { const field = this.getField(name); if (!field) throw new Error(`Field "${name}" not found in action "${this.actionName}"`); - field.getPlainField().value = value; + field.getPlainField().value = encodeFileFieldValue(field.getType(), value, name); const fieldHasHook = field.getPlainField().hook; diff --git a/packages/agent-client/src/action-fields/field-getter.ts b/packages/agent-client/src/action-fields/field-getter.ts index 15e1b42514..bee051c5c5 100644 --- a/packages/agent-client/src/action-fields/field-getter.ts +++ b/packages/agent-client/src/action-fields/field-getter.ts @@ -19,7 +19,7 @@ export default class FieldGetter { return this.plainField.field; } - getType(): string { + getType(): PlainField['type'] { return this.plainField.type; } } diff --git a/packages/agent-client/src/action-fields/file-value.ts b/packages/agent-client/src/action-fields/file-value.ts new file mode 100644 index 0000000000..c6ffa20231 --- /dev/null +++ b/packages/agent-client/src/action-fields/file-value.ts @@ -0,0 +1,49 @@ +import type { PlainField } from './types'; +import type { File } from '@forestadmin/datasource-toolkit'; + +import { makeDataUri } from '@forestadmin/datasource-toolkit'; + +export function isFileType(type: PlainField['type']): boolean { + return type === 'File'; +} + +export function isFileListType(type: PlainField['type']): boolean { + return type === 'FileList' || (Array.isArray(type) && type[0] === 'File'); +} + +function isFile(value: unknown): value is File { + const candidate = value as File; + + return ( + typeof value === 'object' && + Buffer.isBuffer(candidate.buffer) && + typeof candidate.mimeType === 'string' + ); +} + +function encodeFileValue(value: unknown, fieldName: string): unknown { + // Strings pass through untouched: an already encoded data uri stays byte-identical, and + // callers that address the file indirectly (mcp-server upload handles) keep their sentinel. + if (value === null || value === undefined || typeof value === 'string') return value; + + if (isFile(value)) return makeDataUri(value); + + throw new Error( + `Field "${fieldName}" expects a file: pass { buffer, mimeType, name } ` + + 'or a string holding an already encoded data uri.', + ); +} + +export default function encodeFileFieldValue( + type: PlainField['type'], + value: unknown, + fieldName: string, +): unknown { + if (isFileListType(type)) { + return Array.isArray(value) ? value.map(item => encodeFileValue(item, fieldName)) : value; + } + + if (isFileType(type)) return encodeFileValue(value, fieldName); + + return value; +} diff --git a/packages/agent-client/src/action-fields/types.ts b/packages/agent-client/src/action-fields/types.ts index 58855a256d..708f395f17 100644 --- a/packages/agent-client/src/action-fields/types.ts +++ b/packages/agent-client/src/action-fields/types.ts @@ -12,7 +12,8 @@ export type PlainFieldOption = { export type PlainField = { field: string; - type: string; + // Agents emit list types as a single-element array, e.g. ['File'] or ['String']. + type: string | [string]; description?: string; value?: unknown; isRequired: boolean; diff --git a/packages/agent-client/src/index.ts b/packages/agent-client/src/index.ts index c763fa75ab..9d4427c00e 100644 --- a/packages/agent-client/src/index.ts +++ b/packages/agent-client/src/index.ts @@ -68,3 +68,4 @@ export function createRemoteAgentClient(params: { } export type { RecordId, SelectOptions } from './types'; +export type { File } from '@forestadmin/datasource-toolkit'; diff --git a/packages/agent-client/test/action-fields/file-value.test.ts b/packages/agent-client/test/action-fields/file-value.test.ts new file mode 100644 index 0000000000..c21bf43e2e --- /dev/null +++ b/packages/agent-client/test/action-fields/file-value.test.ts @@ -0,0 +1,169 @@ +import type { PlainField } from '../../src/action-fields/types'; +import type HttpRequester from '../../src/http-requester'; + +import FieldFormStates from '../../src/action-fields/field-form-states'; + +jest.mock('../../src/http-requester', () => { + const actual = jest.requireActual('../../src/http-requester'); + + return { __esModule: true, default: actual.default }; +}); + +const pdf = { mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4'), name: 'report.pdf' }; +const pdfDataUri = `data:application/pdf;name=report.pdf;base64,${Buffer.from('%PDF-1.4').toString( + 'base64', +)}`; + +describe('file values in action forms', () => { + let httpRequester: jest.Mocked; + let fieldFormStates: FieldFormStates; + + const setupFields = async (fields: Partial[]) => { + httpRequester.query.mockResolvedValue({ + fields: fields.map(f => ({ isRequired: false, isReadOnly: false, ...f })), + layout: [], + }); + await fieldFormStates.loadInitialState(); + httpRequester.query.mockClear(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + httpRequester = { query: jest.fn() } as unknown as jest.Mocked; + fieldFormStates = new FieldFormStates( + 'attachDocument', + '/forest/actions/attach-document', + 'operations', + httpRequester, + ['1'], + ); + }); + + describe('on a File field', () => { + it('encodes a file object as the data uri the agent expects', async () => { + await setupFields([{ field: 'document', type: 'File' }]); + + await fieldFormStates.setFieldValue('document', pdf); + + expect(fieldFormStates.getFieldValues()).toEqual({ document: pdfDataUri }); + }); + + it('percent-encodes the name so a filename cannot break the header', async () => { + await setupFields([{ field: 'document', type: 'File' }]); + + await fieldFormStates.setFieldValue('document', { ...pdf, name: 'rapport final;v2.pdf' }); + + expect(fieldFormStates.getFieldValues().document).toBe( + `data:application/pdf;name=rapport%20final%3Bv2.pdf;base64,${Buffer.from( + '%PDF-1.4', + ).toString('base64')}`, + ); + }); + + it('encodes the charset when the file carries one', async () => { + await setupFields([{ field: 'document', type: 'File' }]); + + await fieldFormStates.setFieldValue('document', { + mimeType: 'text/csv', + buffer: Buffer.from('a,b'), + name: 'data.csv', + charset: 'utf-8', + }); + + expect(fieldFormStates.getFieldValues().document).toContain(';charset=utf-8;'); + }); + + it('leaves an already encoded data uri byte-identical', async () => { + await setupFields([{ field: 'document', type: 'File' }]); + + await fieldFormStates.setFieldValue('document', pdfDataUri); + + expect(fieldFormStates.getFieldValues()).toEqual({ document: pdfDataUri }); + }); + + it('leaves an opaque string reference untouched', async () => { + await setupFields([{ field: 'document', type: 'File' }]); + + await fieldFormStates.setFieldValue('document', '$uploadedFile:some-token'); + + expect(fieldFormStates.getFieldValues()).toEqual({ document: '$uploadedFile:some-token' }); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ])('leaves %s untouched', async (_, value) => { + await setupFields([{ field: 'document', type: 'File' }]); + + await expect(fieldFormStates.setFieldValue('document', value)).resolves.not.toThrow(); + }); + + it.each([ + ['a bare buffer', Buffer.from('%PDF-1.4')], + ['an object that is not a file', { foo: 1 }], + ['a file without a mime type', { buffer: Buffer.from('x'), name: 'x.pdf' }], + ])('rejects %s with an actionable message', async (_, value) => { + await setupFields([{ field: 'document', type: 'File' }]); + + await expect(fieldFormStates.setFieldValue('document', value)).rejects.toThrow( + 'Field "document" expects a file: pass { buffer, mimeType, name } ' + + 'or a string holding an already encoded data uri.', + ); + }); + }); + + describe('on a file list field', () => { + it.each([['FileList'], [['File']]])('encodes each item of a %p field', async type => { + await setupFields([{ field: 'attachments', type: type as PlainField['type'] }]); + + await fieldFormStates.setFieldValue('attachments', [pdf, '$uploadedFile:token']); + + expect(fieldFormStates.getFieldValues()).toEqual({ + attachments: [pdfDataUri, '$uploadedFile:token'], + }); + }); + + it('leaves a non-array value untouched', async () => { + await setupFields([{ field: 'attachments', type: ['File'] }]); + + await fieldFormStates.setFieldValue('attachments', null); + + expect(fieldFormStates.getFieldValues()).toEqual({ attachments: null }); + }); + }); + + describe('on a field that is not a file', () => { + it('does not encode, because the declared type governs', async () => { + await setupFields([{ field: 'comment', type: 'String' }]); + + await fieldFormStates.setFieldValue('comment', pdf); + + expect(fieldFormStates.getFieldValues()).toEqual({ comment: pdf }); + }); + }); + + describe('when the file field declares a change hook', () => { + it('sends the encoded value to the agent', async () => { + await setupFields([{ field: 'document', type: 'File', hook: 'changeHook' }]); + httpRequester.query.mockResolvedValue({ fields: [], layout: [] }); + + await fieldFormStates.setFieldValue('document', pdf); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'post', + path: '/forest/actions/attach-document/hooks/change', + body: { + data: { + attributes: { + collection_name: 'operations', + changed_field: 'document', + ids: ['1'], + fields: [expect.objectContaining({ field: 'document', value: pdfDataUri })], + }, + type: 'custom-action-hook-requests', + }, + }, + }); + }); + }); +}); From ebc69acceba0304065e2df602ad3257d8755882c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 10 Aug 2026 14:21:34 +0200 Subject: [PATCH 03/39] refactor: reuse the shared data uri codec The agent and plugin-aws-s3 each carried their own copy of the same parser and encoder, and plugin-aws-s3 redeclared the File type. Both now use the datasource-toolkit implementation. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/utils/forest-schema/action-values.ts | 59 ++++--------------- packages/plugin-aws-s3/src/types.ts | 7 +-- packages/plugin-aws-s3/src/utils/data-uri.ts | 38 +----------- 3 files changed, 14 insertions(+), 90 deletions(-) diff --git a/packages/agent/src/utils/forest-schema/action-values.ts b/packages/agent/src/utils/forest-schema/action-values.ts index d2a3291540..d617ba9bcc 100644 --- a/packages/agent/src/utils/forest-schema/action-values.ts +++ b/packages/agent/src/utils/forest-schema/action-values.ts @@ -1,5 +1,7 @@ import type { ActionField, CompositeId, DataSource, File } from '@forestadmin/datasource-toolkit'; +import { isDataUri, makeDataUri, parseDataUri } from '@forestadmin/datasource-toolkit'; + import ActionFields from './action-fields'; import SchemaGeneratorActions from './generator-actions'; import IdUtils from '../id'; @@ -28,9 +30,9 @@ export default class ForestValueConverter { data[key] = IdUtils.unpackId(collection.schema, value as string); } else if (ActionFields.isFileField(field) && value) { - data[key] = this.parseDataUri(value as string); + data[key] = parseDataUri(value as string); } else if (ActionFields.isFileListField(field) && value) { - data[key] = (value as string[])?.map(v => this.parseDataUri(v)); + data[key] = (value as string[])?.map(v => parseDataUri(v)); } else { data[key] = value; } @@ -56,9 +58,9 @@ export default class ForestValueConverter { const collection = dataSource.getCollection(collectionName); data[field.field] = IdUtils.unpackId(collection.schema, field.value as string); } else if (field.type === 'File') { - data[field.field] = this.parseDataUri(field.value as string); + data[field.field] = parseDataUri(field.value as string); } else if (Array.isArray(field.type) && field.type[0] === 'File') { - data[field.field] = (field.value as string[])?.map(v => this.parseDataUri(v)); + data[field.field] = (field.value as string[])?.map(v => parseDataUri(v)); } else { data[field.field] = field.value; } @@ -83,10 +85,10 @@ export default class ForestValueConverter { for (const [key, value] of Object.entries(rawData)) { // Skip fields from the default form if (!SchemaGeneratorActions.defaultFields.map(f => f.field).includes(key)) { - if (Array.isArray(value) && value.every(v => this.isDataUri(v))) { - data[key] = value.map(uri => this.parseDataUri(uri)); - } else if (this.isDataUri(value)) { - data[key] = this.parseDataUri(value as string); + if (Array.isArray(value) && value.every(v => isDataUri(v))) { + data[key] = value.map(uri => parseDataUri(uri)); + } else if (isDataUri(value)) { + data[key] = parseDataUri(value as string); } else { data[key] = value; } @@ -110,50 +112,13 @@ export default class ForestValueConverter { } if (field.type === 'File') { - return this.makeDataUri(value as File); + return makeDataUri(value as File); } if (field.type === 'FileList') { - return (value as File[])?.map(f => this.makeDataUri(f)); + return (value as File[])?.map(f => makeDataUri(f)); } return value; } - - private static parseDataUri(dataUri: string): File { - if (!dataUri) return null; - - // Poor man's data uri parser (spec compliants one don't get the filename). - // Hopefully this does not break. - const [header, data] = dataUri.substring(5).split(','); - const [mimeType, ...mediaTypes] = header.split(';'); - const result = { mimeType, buffer: Buffer.from(data, 'base64') }; - - for (const mediaType of mediaTypes) { - const index = mediaType.indexOf('='); - - if (index !== -1) { - result[mediaType.substring(0, index)] = decodeURIComponent(mediaType.substring(index + 1)); - } - } - - return result as File; - } - - private static makeDataUri(file: File): string { - if (!file) return null; - - const { mimeType, buffer, ...rest } = file; - const mediaTypes = Object.entries(rest) - .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) - .join(';'); - - return mediaTypes.length - ? `data:${file.mimeType};${mediaTypes};base64,${buffer.toString('base64')}` - : `data:${file.mimeType};base64,${buffer.toString('base64')}`; - } - - private static isDataUri(value: unknown): boolean { - return typeof value === 'string' && value.startsWith('data:'); - } } diff --git a/packages/plugin-aws-s3/src/types.ts b/packages/plugin-aws-s3/src/types.ts index e229ab0257..09064ee743 100644 --- a/packages/plugin-aws-s3/src/types.ts +++ b/packages/plugin-aws-s3/src/types.ts @@ -10,12 +10,7 @@ import type { import type CollectionCustomizationContext from '@forestadmin/datasource-customizer/dist/context/collection-context'; import type WriteCustomizationContext from '@forestadmin/datasource-customizer/dist/decorators/write/write-replace/context'; -export type File = { - name: string; - buffer: Buffer; - mimeType: string; - charset?: string; -}; +export type { File } from '@forestadmin/datasource-toolkit'; /** * Configuration for the AWS S3 addon of Forest Admin. diff --git a/packages/plugin-aws-s3/src/utils/data-uri.ts b/packages/plugin-aws-s3/src/utils/data-uri.ts index 42893cb009..3f0c7e4a25 100644 --- a/packages/plugin-aws-s3/src/utils/data-uri.ts +++ b/packages/plugin-aws-s3/src/utils/data-uri.ts @@ -1,37 +1 @@ -import type { File } from '../types'; - -export function parseDataUri(dataUri: string): File { - if (!dataUri) return null; - - const [header, data] = dataUri.substring(5).split(','); - const [mimeType, ...mediaTypes] = header.split(';'); - const result = { mimeType, buffer: Buffer.from(data, 'base64') }; - - for (const mediaType of mediaTypes) { - const index = mediaType.indexOf('='); - - if (index !== -1) { - result[mediaType.substring(0, index)] = decodeURIComponent(mediaType.substring(index + 1)); - } - } - - return result as unknown as File; -} - -export function encodeDataUri(data: File): string { - // prefix - let uri = `data:${data.mimeType}`; - - // media types - const mediaTypes = Object.entries(data) - .filter(([mediaType, value]) => value && mediaType !== 'mimeType' && mediaType !== 'buffer') - .map(([mediaType, value]) => `${mediaType}=${encodeURIComponent(value as string)}`) - .join(';'); - - if (mediaTypes.length) uri += `;${mediaTypes}`; - - // data - uri += `;base64,${data.buffer.toString('base64')}`; - - return uri; -} +export { parseDataUri, makeDataUri as encodeDataUri } from '@forestadmin/datasource-toolkit'; From d0e86821b37a21e92241d9cc357c9fc5ff59af95 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 10 Aug 2026 14:29:23 +0200 Subject: [PATCH 04/39] feat(mcp-server): enable action file fields via an upload side-channel Action forms with File fields could not run over MCP: the agent expects a data uri, so the bytes had to travel through the model's context window and exceeded most clients' payload limits. POST /files returns a pre-authorized upload URL plus a signed handle bound to the requesting user, the client uploads straight to the storage backend, and executeAction swaps the handle for the uploaded file before calling the agent. The model only ever exchanges the small handle. Redemption enforces a size cap, an optional sha256 pin, and a download concurrency bound. The storage backend is pluggable and adds no dependency to the package. Co-authored-by: Stefano Amorelli Co-Authored-By: Claude Opus 5 (1M context) --- packages/mcp-server/CLAUDE.md | 1 + packages/mcp-server/README.md | 104 +++++++++ .../src/file-uploads/file-reference.ts | 21 ++ .../mcp-server/src/file-uploads/handles.ts | 58 +++++ .../mcp-server/src/file-uploads/resolve.ts | 117 ++++++++++ .../mcp-server/src/file-uploads/routes.ts | 119 ++++++++++ .../mcp-server/src/file-uploads/semaphore.ts | 39 ++++ packages/mcp-server/src/file-uploads/types.ts | 94 ++++++++ packages/mcp-server/src/index.ts | 1 + packages/mcp-server/src/mcp-paths.ts | 19 +- packages/mcp-server/src/server.ts | 46 +++- packages/mcp-server/src/tool-context.ts | 2 + .../mcp-server/src/tools/execute-action.ts | 19 +- .../test/file-uploads/file-reference.test.ts | 26 +++ .../test/file-uploads/handles.test.ts | 65 ++++++ .../test/file-uploads/resolve.test.ts | 217 ++++++++++++++++++ .../test/file-uploads/routes.test.ts | 187 +++++++++++++++ packages/mcp-server/test/mcp-paths.test.ts | 22 ++ .../test/tools/execute-action.test.ts | 133 +++++++++++ 19 files changed, 1278 insertions(+), 12 deletions(-) create mode 100644 packages/mcp-server/src/file-uploads/file-reference.ts create mode 100644 packages/mcp-server/src/file-uploads/handles.ts create mode 100644 packages/mcp-server/src/file-uploads/resolve.ts create mode 100644 packages/mcp-server/src/file-uploads/routes.ts create mode 100644 packages/mcp-server/src/file-uploads/semaphore.ts create mode 100644 packages/mcp-server/src/file-uploads/types.ts create mode 100644 packages/mcp-server/test/file-uploads/file-reference.test.ts create mode 100644 packages/mcp-server/test/file-uploads/handles.test.ts create mode 100644 packages/mcp-server/test/file-uploads/resolve.test.ts create mode 100644 packages/mcp-server/test/file-uploads/routes.test.ts diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 23cadc4e31..41de211780 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -16,6 +16,7 @@ Key flows that only make sense across files: - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). +- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. `POST /files` (bearer-protected, `mcp:action` scope, only mounted when enabled) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. When enabled, `makeIsMcpRoute(prefix, { fileUploads: true })` also claims `/files`. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. ## Commands diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 8757681d97..2ed803ff39 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -164,6 +164,109 @@ The two settings differ in what the user notices: The minimum for either value is 60 seconds; anything lower is raised to it. An invalid value (zero, negative, fractional) fails at startup rather than silently leaving the tokens uncapped. +## Action File Uploads + +> **Experimental.** The MCP specification is still designing its own file transfer story +> ([SEP-2631](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631)). The +> `UploadStorage` contract is expected to survive, but the `POST /files` route and the handle +> format may change to follow the specification once it lands. + +Actions with **File fields** cannot normally run over MCP. The agent expects file values as data +uris, which would transit the model's context window and exceed most MCP clients' payload limits. +The `fileUploads` option enables them through an upload side-channel that keeps the bytes out of +the conversation: + +1. The client `POST`s `/files` (same Bearer token as `/mcp`) with `{ "filename", "mimeType", "sha256"? }` and receives a pre-authorized upload URL plus a signed `fileHandle` string. +2. The client uploads the raw bytes directly to the storage backend, so they never pass through the MCP server or the model. +3. The client passes the handle (`"$uploadedFile:<...>"`) as the field value in `executeAction`. The server downloads the object and hands it to the agent. The model only ever exchanges the small handle. + +```mermaid +sequenceDiagram + participant Client as MCP client + participant Server as MCP server + participant Storage as Storage backend + participant Agent as Forest Admin agent + + Client->>Server: POST /files {filename, mimeType, sha256?} + Server-->>Client: uploadUrl + fileHandle (user-bound JWT) + Client->>Storage: PUT raw bytes to uploadUrl + Note over Client,Storage: bytes bypass the server and the model + Client->>Server: executeAction {values: {field: "$uploadedFile:..."}} + Server->>Storage: download object + Note over Server: verify user, TTL, maxBytes, sha256 pin + Server->>Agent: executeAction with the file + Agent-->>Server: action result + Server-->>Client: result (the model only saw the handle) +``` + +The storage backend is pluggable, and this package has no storage dependency. Provide an +implementation of `UploadStorage`; any backend that can pre-authorize an upload and read the object +back works, such as S3 presigned URLs (below), GCS signed URLs, Azure SAS, or an endpoint you serve +yourself. The only hard requirement is that the URL be reachable from the MCP client, since that is +what uploads the bytes. + +```typescript +import { + S3Client, + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { UploadStorage } from '@forestadmin/mcp-server'; + +const s3 = new S3Client({}); +const bucket = 'my-uploads-bucket'; + +const storage: UploadStorage = { + async createUploadUrl({ key, mimeType, sha256, expiresInSeconds }) { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: mimeType, + ...(sha256 && { ChecksumSHA256: sha256 }), + }); + const url = await getSignedUrl(s3, command, { + expiresIn: expiresInSeconds, + ...(sha256 && { unhoistableHeaders: new Set(['x-amz-checksum-sha256']) }), + }); + return { + url, + headers: { 'Content-Type': mimeType, ...(sha256 && { 'x-amz-checksum-sha256': sha256 }) }, + }; + }, + async getSize(key) { + const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); + return head.ContentLength; + }, + async download(key) { + const object = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + return Buffer.from(await object.Body.transformToByteArray()); + }, +}; + +const server = new ForestMCPServer({ + // ... + fileUploads: { storage }, +}); +``` + +The other options are `keyPrefix` (default `mcp-uploads/`), `uploadUrlTtlSeconds` (default 15 min), +`handleTtlSeconds` (default 45 min, longer than the upload URL so a slow upload still leaves time to +run the action), `maxBytes` (default 20 MiB), and `maxConcurrentDownloads` (default 5). + +A few properties matter in production. + +- The server stays stateless. The handle is a JWT signed with `authSecret`, so there is no database and no session affinity, and any replica can redeem a handle issued by another. +- A handle is bound to the user it was issued to. Only that user's Bearer token can redeem it, and it expires with `handleTtlSeconds`. It stays redeemable until then, so keep the TTL short. +- When the client sends `sha256` (hex or base64), the upload URL is pinned to that digest and the digest is checked again on the downloaded bytes at redemption. Content substituted after an upload URL leak cannot be redeemed. +- **`maxBytes` does not bound memory on its own.** A pre-authorized upload URL cannot always cap the object size, so the limit is enforced at redemption: before downloading when `getSize` reports a size, and only after the bytes are in memory when it returns `undefined`. Implement `getSize` whenever the backend can answer it cheaply. `maxConcurrentDownloads` bounds how many redemptions hold a file at once. +- The server never deletes objects. Configure a lifecycle rule on the storage backend, for example deleting objects under `keyPrefix` after one day. + +Only `executeAction` resolves handles. `getActionForm` echoes field values back to the model, so a +handle stays a handle there: resolving it would put the file content back into the model's context. +A file field that declares a change hook therefore sends the unresolved handle to that hook. + ## API Endpoints Once running, the MCP server exposes the following endpoints: @@ -171,6 +274,7 @@ Once running, the MCP server exposes the following endpoints: | Method | Path | Description | |--------|------|-------------| | POST | `/mcp` | Main MCP protocol endpoint (requires Bearer token) | +| POST | `/files` | Upload side-channel for action file fields (only with `fileUploads`; requires Bearer token) | | POST | `/oauth/authorize` | OAuth 2.0 authorization | | POST | `/oauth/token` | OAuth 2.0 token exchange | | GET | `/.well-known/oauth-protected-resource/mcp` | OAuth metadata discovery | diff --git a/packages/mcp-server/src/file-uploads/file-reference.ts b/packages/mcp-server/src/file-uploads/file-reference.ts new file mode 100644 index 0000000000..47b99403de --- /dev/null +++ b/packages/mcp-server/src/file-uploads/file-reference.ts @@ -0,0 +1,21 @@ +/** + * Sentinel prefix used inside action form values, e.g. + * { "document": "$uploadedFile:" } + * A string rather than an object, so it passes the agent-client field validation and stays + * cheap when getActionForm echoes it back into the model's context. + */ +export const UPLOADED_FILE_PREFIX = '$uploadedFile:'; + +export type FileReference = { kind: 'uploadHandle'; handle: string }; + +/** + * Recognizes the values that stand for a file the server must fetch itself. + * + * Isolated so that the file URIs the MCP specification is designing (SEP-2631) can be added + * as another kind without touching the resolution path. + */ +export default function parseFileReference(value: unknown): FileReference | null { + if (typeof value !== 'string' || !value.startsWith(UPLOADED_FILE_PREFIX)) return null; + + return { kind: 'uploadHandle', handle: value.slice(UPLOADED_FILE_PREFIX.length) }; +} diff --git a/packages/mcp-server/src/file-uploads/handles.ts b/packages/mcp-server/src/file-uploads/handles.ts new file mode 100644 index 0000000000..3f94a3bcb8 --- /dev/null +++ b/packages/mcp-server/src/file-uploads/handles.ts @@ -0,0 +1,58 @@ +import jsonwebtoken from 'jsonwebtoken'; + +const HANDLE_TYPE = 'mcp-upload'; + +export interface UploadHandleClaims { + key: string; + name: string; + mimeType: string; + /** Base64 sha256 the upload was pinned to, when the client provided one. */ + sha256?: string; +} + +export function signUploadHandle( + claims: UploadHandleClaims & { userId: number | string }, + authSecret: string, + ttlSeconds: number, +): string { + return jsonwebtoken.sign( + { + type: HANDLE_TYPE, + key: claims.key, + name: claims.name, + mime: claims.mimeType, + uploader: String(claims.userId), + ...(claims.sha256 && { sha256: claims.sha256 }), + }, + authSecret, + { expiresIn: ttlSeconds }, + ); +} + +/** Throws on tampered, expired, or cross-user handles. */ +export function verifyUploadHandle( + handle: string, + userId: number | string, + authSecret: string, +): UploadHandleClaims { + const decoded = jsonwebtoken.verify(handle, authSecret) as { + type?: string; + key: string; + name: string; + mime: string; + uploader?: string; + sha256?: string; + }; + + // The handle is signed with the secret that also signs access tokens, so the type claim is + // what keeps an access token from being redeemed as an upload handle. + if (decoded?.type !== HANDLE_TYPE) throw new Error('Not an upload handle'); + if (decoded.uploader !== String(userId)) throw new Error('Handle was issued to another user'); + + return { + key: decoded.key, + name: decoded.name, + mimeType: decoded.mime, + sha256: decoded.sha256, + }; +} diff --git a/packages/mcp-server/src/file-uploads/resolve.ts b/packages/mcp-server/src/file-uploads/resolve.ts new file mode 100644 index 0000000000..87a3ebf4c0 --- /dev/null +++ b/packages/mcp-server/src/file-uploads/resolve.ts @@ -0,0 +1,117 @@ +import type { ResolvedFileUploads } from './types'; +import type { File } from '@forestadmin/agent-client'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; + +import * as crypto from 'crypto'; + +import parseFileReference from './file-reference'; +import { verifyUploadHandle } from './handles'; + +function collectReferences(values: Record): Set { + const references = new Set(); + + for (const value of Object.values(values)) { + const candidates = Array.isArray(value) ? value : [value]; + + candidates.forEach(candidate => { + if (parseFileReference(candidate)) references.add(candidate as string); + }); + } + + return references; +} + +async function loadFile( + reference: string, + userId: number | string, + uploads: ResolvedFileUploads, +): Promise { + const { handle } = parseFileReference(reference); + const claims = verifyUploadHandle(handle, userId, uploads.authSecret); + + // A pre-authorized upload URL cannot always cap the object size, so the limit is enforced + // here, before the bytes are read whenever the backend can report a size. + const size = await uploads.storage.getSize(claims.key); + + if (size !== undefined && size > uploads.maxBytes) { + throw new Error(`Uploaded file is ${size} bytes, above the ${uploads.maxBytes} byte limit`); + } + + const buffer = await uploads.storage.download(claims.key); + + if (buffer.length === 0) { + throw new Error('Uploaded file is empty. Did the upload to uploadUrl succeed?'); + } + + if (buffer.length > uploads.maxBytes) { + throw new Error( + `Uploaded file is ${buffer.length} bytes, above the ${uploads.maxBytes} byte limit`, + ); + } + + // Even if the upload URL leaked and someone overwrote the object, substituted content + // cannot be redeemed. + if (claims.sha256) { + const digest = crypto.createHash('sha256').update(new Uint8Array(buffer)).digest('base64'); + + if (digest !== claims.sha256) { + throw new Error('Uploaded file does not match the sha256 it was pinned to'); + } + } + + return { buffer, mimeType: claims.mimeType, name: claims.name }; +} + +/** + * Replaces the file references in action form values with the uploaded objects. agent-client + * encodes them for the agent, so the model only ever exchanges the small reference. + * + * References are resolved concurrently and deduplicated, so one referenced by several fields + * is downloaded once. + * + * Only executeAction resolves them. getActionForm echoes field values back to the model, and + * a resolved file there would put the content back into the model's context. + */ +export default async function resolveUploadedFileValues( + values: Record, + authInfo: AuthInfo | undefined, + uploads: ResolvedFileUploads | undefined, +): Promise> { + const references = collectReferences(values); + + if (references.size === 0) return values; + + if (!uploads) { + throw new Error( + 'File uploads are not configured on this server. ' + + 'Ask the administrator to set the fileUploads option to enable action file fields.', + ); + } + + const userId = authInfo?.extra?.userId as number | string | undefined; + + if (userId === undefined || userId === null) { + throw new Error('Cannot resolve uploaded files without an authenticated user'); + } + + const files = new Map( + await Promise.all( + [...references].map( + async (reference): Promise<[string, File]> => [ + reference, + await uploads.limitDownload(() => loadFile(reference, userId, uploads)), + ], + ), + ), + ); + + const substitute = (value: unknown) => + files.has(value as string) ? files.get(value as string) : value; + + return Object.fromEntries( + Object.entries(values).map(([field, value]) => [ + field, + Array.isArray(value) ? value.map(substitute) : substitute(value), + ]), + ); +} diff --git a/packages/mcp-server/src/file-uploads/routes.ts b/packages/mcp-server/src/file-uploads/routes.ts new file mode 100644 index 0000000000..a06ade40cc --- /dev/null +++ b/packages/mcp-server/src/file-uploads/routes.ts @@ -0,0 +1,119 @@ +import type { ResolvedFileUploads } from './types'; +import type { Logger } from '../server'; +import type { Request, Response, Router } from 'express'; + +import * as crypto from 'crypto'; +import express from 'express'; + +import { UPLOADED_FILE_PREFIX } from './file-reference'; +import { signUploadHandle } from './handles'; + +const MIME_TYPE_PATTERN = /^[\w.+-]+\/[\w.+-]+$/; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; +const SHA256_BASE64_PATTERN = /^[A-Za-z0-9+/]{43}=$/; +const MAX_FILENAME_LENGTH = 128; + +// The storage key delimits segments with '/', so keep a conservative charset. The data uri +// encoding is agent-client's job and percent-encodes whatever survives here. +function sanitizeFilename(filename: string): string { + return filename + .trim() + .slice(-MAX_FILENAME_LENGTH) + .replace(/[^\w.\- ()]/g, '_'); +} + +// Accepts the digest as hex (shasum -a 256 output) or base64. Returns base64, null when the +// digest is absent, or false when it is malformed. +function normalizeSha256(sha256: unknown): string | null | false { + if (sha256 === undefined || sha256 === null || sha256 === '') return null; + if (typeof sha256 !== 'string') return false; + if (SHA256_BASE64_PATTERN.test(sha256)) return sha256; + if (SHA256_HEX_PATTERN.test(sha256)) return Buffer.from(sha256, 'hex').toString('base64'); + + return false; +} + +/** + * Router for POST /files, the upload half of the action file side-channel. + * + * Must be mounted behind requireBearerAuth so req.auth carries the caller's identity: the + * returned handle is bound to that user and can only be redeemed by them. + */ +export default function createFilesRouter(uploads: ResolvedFileUploads, logger: Logger): Router { + const router = express.Router(); + + router.post('/', async (req: Request, res: Response) => { + const userId = req.auth?.extra?.userId; + + if (userId === undefined || userId === null) { + res.status(401).json({ error: 'Missing or invalid access token.' }); + + return; + } + + const { filename, mimeType, sha256 } = (req.body ?? {}) as Record; + + if (typeof filename !== 'string' || !filename.trim()) { + res.status(400).json({ error: 'filename is required.' }); + + return; + } + + if (typeof mimeType !== 'string' || !MIME_TYPE_PATTERN.test(mimeType)) { + res.status(400).json({ error: 'mimeType is required, e.g. application/pdf.' }); + + return; + } + + const sha256Base64 = normalizeSha256(sha256); + + if (sha256Base64 === false) { + res.status(400).json({ error: 'sha256 must be the file digest as hex or base64.' }); + + return; + } + + const safeName = sanitizeFilename(filename); + const key = `${uploads.keyPrefix}${crypto.randomUUID()}/${safeName}`; + + const destination = await uploads.storage.createUploadUrl({ + key, + mimeType, + ...(sha256Base64 && { sha256: sha256Base64 }), + expiresInSeconds: uploads.uploadUrlTtlSeconds, + }); + + const handle = signUploadHandle( + { + key, + name: safeName, + mimeType, + userId: userId as number | string, + ...(sha256Base64 && { sha256: sha256Base64 }), + }, + uploads.authSecret, + uploads.handleTtlSeconds, + ); + + res.json({ + uploadUrl: destination.url, + method: destination.method ?? 'PUT', + headers: destination.headers ?? { 'Content-Type': mimeType }, + expiresInSeconds: uploads.uploadUrlTtlSeconds, + maxBytes: uploads.maxBytes, + fileHandle: `${UPLOADED_FILE_PREFIX}${handle}`, + usage: + 'Upload the raw file bytes to uploadUrl with the given method and headers, ' + + 'then pass fileHandle as the value of the action file field in executeAction. ' + + 'Provide sha256 in the request to pin the upload to that exact content.', + }); + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- error handlers need arity 4 + router.use((error: Error, req: Request, res: Response, next: express.NextFunction) => { + logger('Error', `/files error: ${error.message}`); + res.status(500).json({ error: 'Failed to create upload URL.' }); + }); + + return router; +} diff --git a/packages/mcp-server/src/file-uploads/semaphore.ts b/packages/mcp-server/src/file-uploads/semaphore.ts new file mode 100644 index 0000000000..88fa7bdb29 --- /dev/null +++ b/packages/mcp-server/src/file-uploads/semaphore.ts @@ -0,0 +1,39 @@ +export type RunExclusive = (task: () => Promise) => Promise; + +/** + * Bounds concurrent handle redemptions. Each redemption can hold up to maxBytes plus its + * base64 copy in memory, so the process's worst case stays bounded by the limit instead of + * by whatever load arrives. + */ +export default function createSemaphore(limit: number): RunExclusive { + let active = 0; + const queue: Array<() => void> = []; + + const acquire = () => + new Promise(resolve => { + if (active < limit) { + active += 1; + resolve(); + } else { + queue.push(resolve); + } + }); + + const release = () => { + const next = queue.shift(); + + // The slot transfers to the waiting task, so active stays unchanged. + if (next) next(); + else active -= 1; + }; + + return async (task: () => Promise): Promise => { + await acquire(); + + try { + return await task(); + } finally { + release(); + } + }; +} diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts new file mode 100644 index 0000000000..35bb23583c --- /dev/null +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -0,0 +1,94 @@ +import type { RunExclusive } from './semaphore'; + +import createSemaphore from './semaphore'; + +/** + * Storage backend for the action file upload side-channel. + * + * Implementations are provided by the host application (S3, GCS, Azure...). The MCP server + * never sees the file bytes during upload: clients PUT them straight to the URL returned by + * createUploadUrl, and the server only reads them back when an executeAction call redeems + * the handle. + */ +export interface UploadStorage { + /** Return a pre-authorized URL the client can upload a single object to. */ + createUploadUrl(params: { + key: string; + mimeType: string; + /** Base64 sha256 digest the upload must match, when the client pinned one. */ + sha256?: string; + expiresInSeconds: number; + }): Promise<{ url: string; method?: string; headers?: Record }>; + + /** Read the uploaded object back. Must reject when the object does not exist. */ + download(key: string): Promise; + + /** + * Size of the uploaded object, used to reject oversized uploads before downloading them. + * Return undefined when the backend cannot report it cheaply — `maxBytes` is then only + * enforced after the bytes are in memory, so the process holds the whole object either way. + */ + getSize(key: string): Promise; +} + +/** + * Options for the `fileUploads` server option. + * + * @experimental The MCP specification is still designing its own file transfer story + * (SEP-2631). The storage contract is expected to survive, but the `POST /files` route and + * the handle format may change to follow the specification once it lands. + */ +export interface FileUploadsOptions { + storage: UploadStorage; + /** Key prefix for uploaded objects. Defaults to 'mcp-uploads/'. */ + keyPrefix?: string; + /** Lifetime of the upload URL. Defaults to 15 minutes. */ + uploadUrlTtlSeconds?: number; + /** + * Lifetime of the file handle. Defaults to 45 minutes, longer than the upload URL, so a + * slow upload still leaves time to run the action. + */ + handleTtlSeconds?: number; + /** Maximum uploaded file size, enforced when the handle is redeemed. Defaults to 20 MiB. */ + maxBytes?: number; + /** + * Maximum handle redemptions running at once per process. Each redemption may hold up to + * maxBytes plus its base64 copy in memory, so this bounds the worst case. Defaults to 5. + */ + maxConcurrentDownloads?: number; +} + +export interface ResolvedFileUploads { + storage: UploadStorage; + keyPrefix: string; + uploadUrlTtlSeconds: number; + handleTtlSeconds: number; + maxBytes: number; + authSecret: string; + limitDownload: RunExclusive; +} + +const DEFAULT_KEY_PREFIX = 'mcp-uploads/'; +const DEFAULT_UPLOAD_URL_TTL_SECONDS = 15 * 60; +const DEFAULT_HANDLE_TTL_SECONDS = 45 * 60; +const DEFAULT_MAX_BYTES = 20 * 1024 * 1024; +const DEFAULT_MAX_CONCURRENT_DOWNLOADS = 5; + +export function resolveFileUploads( + options: FileUploadsOptions | undefined, + authSecret: string, +): ResolvedFileUploads | undefined { + if (!options) return undefined; + + return { + storage: options.storage, + keyPrefix: options.keyPrefix ?? DEFAULT_KEY_PREFIX, + uploadUrlTtlSeconds: options.uploadUrlTtlSeconds ?? DEFAULT_UPLOAD_URL_TTL_SECONDS, + handleTtlSeconds: options.handleTtlSeconds ?? DEFAULT_HANDLE_TTL_SECONDS, + maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, + authSecret, + limitDownload: createSemaphore( + options.maxConcurrentDownloads ?? DEFAULT_MAX_CONCURRENT_DOWNLOADS, + ), + }; +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 164da65311..1503b74b50 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -2,6 +2,7 @@ export { default as ForestMCPServer } from './server'; export type { ForestMCPServerOptions, HttpCallback, ToolName } from './server'; export type { TokenTtlOptions } from './utils/token-ttl'; +export type { FileUploadsOptions, UploadStorage } from './file-uploads/types'; export type { InProcessAgentDispatcher, InProcessDispatchRequest, diff --git a/packages/mcp-server/src/mcp-paths.ts b/packages/mcp-server/src/mcp-paths.ts index d1eaf3aeb0..3d87278282 100644 --- a/packages/mcp-server/src/mcp-paths.ts +++ b/packages/mcp-server/src/mcp-paths.ts @@ -26,7 +26,12 @@ export function normalizeMountPath(input?: string): string { * Well-known paths stay anchored at the origin root (per RFC 8414/9728) but carry the prefix * as a suffix, so a host's own root OAuth metadata is not claimed. */ -export function buildMcpPaths(prefix = ''): string[] { +export interface McpRouteOptions { + /** Claim the /files upload route too. Only set when the fileUploads option is enabled. */ + fileUploads?: boolean; +} + +export function buildMcpPaths(prefix = '', options: McpRouteOptions = {}): string[] { const normalized = normalizeMountPath(prefix); const wellKnown = normalized @@ -36,11 +41,17 @@ export function buildMcpPaths(prefix = ''): string[] { ] : ['/.well-known/']; - return [...wellKnown, `${normalized}/oauth/`, `${normalized}/mcp`]; + return [ + ...wellKnown, + `${normalized}/oauth/`, + `${normalized}/mcp`, + // Claimed only when uploads are enabled, so a host app's own /files route keeps working. + ...(options.fileUploads ? [`${normalized}/files`] : []), + ]; } -export function makeIsMcpRoute(prefix = ''): McpRouteMatcher { - const paths = buildMcpPaths(prefix); +export function makeIsMcpRoute(prefix = '', options: McpRouteOptions = {}): McpRouteMatcher { + const paths = buildMcpPaths(prefix, options); // Match on the pathname (req.url carries the query string) and on a segment boundary, so // '/mcp?x=1' still matches and '/ai/mcp' does not shadow '/ai/mcp-dashboard'. diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index dab57f6252..04d268af17 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -2,6 +2,7 @@ // This ensures URL.canParse is available for MCP SDK's Zod validation import './polyfills'; +import type { FileUploadsOptions, ResolvedFileUploads } from './file-uploads/types'; import type { ForestServerClient } from './http-client'; import type { InProcessAgentDispatcher } from './in-process-agent-dispatcher'; import type { ToolContext } from './tool-context'; @@ -23,6 +24,8 @@ import cors from 'cors'; import express from 'express'; import * as http from 'http'; +import createFilesRouter from './file-uploads/routes'; +import { resolveFileUploads } from './file-uploads/types'; import ForestOAuthProvider from './forest-oauth-provider'; import { createForestServerClient } from './http-client'; import { makeIsMcpRoute, normalizeMountPath } from './mcp-paths'; @@ -154,6 +157,18 @@ export interface ForestMCPServerOptions { * Omit to accept any dynamically registered client. */ allowedOAuthClients?: string[]; + /** + * Enables file fields in action forms through an upload side-channel. Without it, action + * file fields are unusable over MCP: the agent expects them as data uris, which would + * transit the model's context window and exceed most clients' payload limits. When set, + * POST /files returns a pre-authorized upload URL plus a signed handle, and executeAction + * swaps "$uploadedFile:" values for the uploaded file before calling the agent, so + * the model only ever exchanges the small handle. Requires a storage backend. + * + * @experimental Expected to change to follow the MCP file transfer specification once it + * lands (SEP-2631). + */ + fileUploads?: FileUploadsOptions; } /** @@ -180,6 +195,8 @@ export default class ForestMCPServer { private agentDispatcher?: InProcessAgentDispatcher; private tokenTtl?: TokenTtlOptions; private allowedOAuthClients?: string[]; + private fileUploadsOptions?: FileUploadsOptions; + private fileUploads?: ResolvedFileUploads; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -194,6 +211,8 @@ export default class ForestMCPServer { this.tokenTtl = normalizeTokenTtl(options?.tokenTtl, this.logger); this.allowedOAuthClients = normalizeDomainList(options?.allowedOAuthClients); + // Resolved in buildExpressApp, where the auth secret is known to be set. + this.fileUploadsOptions = options?.fileUploads; // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -230,6 +249,7 @@ export default class ForestMCPServer { logger: this.logger, collectionNames: this.collectionNames, agentDispatcher: this.agentDispatcher, + fileUploads: this.fileUploads, }; const allTools: Array<{ name: ToolName; register: () => string }> = [ @@ -428,6 +448,8 @@ export default class ForestMCPServer { async buildExpressApp(baseUrl?: URL): Promise { const { envSecret, authSecret } = this.ensureSecretsAreSet(); + this.fileUploads = resolveFileUploads(this.fileUploadsOptions, authSecret); + await this.fetchCollectionNames(); const app = express(); @@ -559,15 +581,29 @@ export default class ForestMCPServer { app.use(allowedMethods(['POST'])); + const resourceMetadataUrl = new URL( + `/.well-known/oauth-protected-resource${mcpResourceUrl.pathname}`, + effectiveBaseUrl, + ).href; + + if (this.fileUploads) { + app.use( + `${prefix}/files`, + requireBearerAuth({ + verifier: oauthProvider, + requiredScopes: ['mcp:action'], + resourceMetadataUrl, + }), + createFilesRouter(this.fileUploads, this.logger), + ); + } + app.post( `${prefix}/mcp`, requireBearerAuth({ verifier: oauthProvider, requiredScopes: ['mcp:read'], - resourceMetadataUrl: new URL( - `/.well-known/oauth-protected-resource${mcpResourceUrl.pathname}`, - effectiveBaseUrl, - ).href, + resourceMetadataUrl, }), (req, res) => { this.handleMcpRequest(req, res).catch(error => { @@ -636,7 +672,7 @@ export default class ForestMCPServer { */ async getHttpCallback(baseUrl?: URL): Promise { const app = await this.buildExpressApp(baseUrl); - const isMcpRoute = makeIsMcpRoute(this.basePath); + const isMcpRoute = makeIsMcpRoute(this.basePath, { fileUploads: Boolean(this.fileUploads) }); return (req, res, next) => { const url = req.url || '/'; diff --git a/packages/mcp-server/src/tool-context.ts b/packages/mcp-server/src/tool-context.ts index 2b958d0e14..ce6a1e27f1 100644 --- a/packages/mcp-server/src/tool-context.ts +++ b/packages/mcp-server/src/tool-context.ts @@ -1,3 +1,4 @@ +import type { ResolvedFileUploads } from './file-uploads/types'; import type { ForestServerClient } from './http-client'; import type { InProcessAgentDispatcher } from './in-process-agent-dispatcher'; import type { Logger } from './server'; @@ -7,4 +8,5 @@ export interface ToolContext { logger: Logger; collectionNames: string[]; agentDispatcher?: InProcessAgentDispatcher; + fileUploads?: ResolvedFileUploads; } diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index a587859474..ef82b61fc5 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -3,6 +3,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; +import resolveUploadedFileValues from '../file-uploads/resolve'; import { createActionArgumentShape } from '../utils/action-helpers'; import { buildClientWithActions } from '../utils/agent-caller'; import registerToolWithLogging from '../utils/tool-with-logging'; @@ -41,7 +42,13 @@ Required workflow: 2. If getActionForm returns "canExecute": false, call it again with values until "canExecute": true 3. Only then call executeAction with the same values used in the last getActionForm call -If you call executeAction with missing required fields, it will return an error with the missing fields instead of executing the action.`, +If you call executeAction with missing required fields, it will return an error with the missing fields instead of executing the action.${ + ctx.fileUploads + ? ` + +To fill a file field, never inline base64 file content. Request an upload destination via POST /files (same Bearer token, JSON body with "filename", "mimeType" and optionally "sha256"), upload the raw bytes to the returned uploadUrl, and pass the returned fileHandle string as the field value.` + : '' + }`, inputSchema: argumentShape, }, async (options: ExecuteActionArgument, extra) => { @@ -54,6 +61,12 @@ If you call executeAction with missing required fields, it will return an error // Cast to satisfy the type system - the API accepts both string[] and number[] const recordIds = (options.recordIds ?? []) as string[] | number[]; + // Swap the upload handles for the uploaded files before they reach the form. agent-client + // encodes them for the agent. No-op when no value carries a handle. + const values = options.values + ? await resolveUploadedFileValues(options.values, extra.authInfo, ctx.fileUploads) + : undefined; + return withActivityLog({ forestServerClient, request: extra, @@ -69,8 +82,8 @@ If you call executeAction with missing required fields, it will return an error .collection(options.collectionName) .action(options.actionName, { recordIds }); - if (options.values) { - await action.setFields(options.values); + if (values) { + await action.setFields(values); } const result = await action.execute({ approvalRequestMessage: options.reasoning }); diff --git a/packages/mcp-server/test/file-uploads/file-reference.test.ts b/packages/mcp-server/test/file-uploads/file-reference.test.ts new file mode 100644 index 0000000000..a65839b327 --- /dev/null +++ b/packages/mcp-server/test/file-uploads/file-reference.test.ts @@ -0,0 +1,26 @@ +import parseFileReference, { UPLOADED_FILE_PREFIX } from '../../src/file-uploads/file-reference'; + +describe('parseFileReference', () => { + it('exposes the sentinel prefix used inside action values', () => { + expect(UPLOADED_FILE_PREFIX).toBe('$uploadedFile:'); + }); + + it('extracts the handle of an upload reference', () => { + expect(parseFileReference('$uploadedFile:a.b.c')).toEqual({ + kind: 'uploadHandle', + handle: 'a.b.c', + }); + }); + + it.each([ + ['a plain string', 'report.pdf'], + ['a data uri', 'data:application/pdf;base64,JVBERg=='], + ['a prefix appearing mid-string', 'see $uploadedFile:token'], + ['a number', 42], + ['null', null], + ['undefined', undefined], + ['an object', { handle: 'x' }], + ])('returns null for %s', (_, value) => { + expect(parseFileReference(value)).toBeNull(); + }); +}); diff --git a/packages/mcp-server/test/file-uploads/handles.test.ts b/packages/mcp-server/test/file-uploads/handles.test.ts new file mode 100644 index 0000000000..a245701a5d --- /dev/null +++ b/packages/mcp-server/test/file-uploads/handles.test.ts @@ -0,0 +1,65 @@ +import jsonwebtoken from 'jsonwebtoken'; + +import { signUploadHandle, verifyUploadHandle } from '../../src/file-uploads/handles'; + +const AUTH_SECRET = 'test-auth-secret'; + +const claims = { + key: 'mcp-uploads/uuid/report.pdf', + name: 'report.pdf', + mimeType: 'application/pdf', + userId: 42, +}; + +describe('upload handles', () => { + it('round-trips the claims for the same user', () => { + const handle = signUploadHandle(claims, AUTH_SECRET, 60); + + expect(verifyUploadHandle(handle, 42, AUTH_SECRET)).toEqual({ + key: 'mcp-uploads/uuid/report.pdf', + name: 'report.pdf', + mimeType: 'application/pdf', + sha256: undefined, + }); + }); + + it('carries the sha256 pin when provided', () => { + const handle = signUploadHandle({ ...claims, sha256: 'digest==' }, AUTH_SECRET, 60); + + expect(verifyUploadHandle(handle, 42, AUTH_SECRET).sha256).toBe('digest=='); + }); + + it('accepts a user id whose type differs between signing and redemption', () => { + const handle = signUploadHandle(claims, AUTH_SECRET, 60); + + expect(verifyUploadHandle(handle, '42', AUTH_SECRET).key).toBe('mcp-uploads/uuid/report.pdf'); + }); + + it('rejects a handle redeemed by another user', () => { + const handle = signUploadHandle(claims, AUTH_SECRET, 60); + + expect(() => verifyUploadHandle(handle, 43, AUTH_SECRET)).toThrow( + 'Handle was issued to another user', + ); + }); + + it('rejects an expired handle', () => { + const handle = signUploadHandle(claims, AUTH_SECRET, -1); + + expect(() => verifyUploadHandle(handle, 42, AUTH_SECRET)).toThrow('jwt expired'); + }); + + it('rejects a handle signed with another secret', () => { + const handle = signUploadHandle(claims, 'other-secret', 60); + + expect(() => verifyUploadHandle(handle, 42, AUTH_SECRET)).toThrow('invalid signature'); + }); + + it('rejects an access token that was signed with the same secret', () => { + const accessTokenLookalike = jsonwebtoken.sign({ id: 42 }, AUTH_SECRET, { expiresIn: 60 }); + + expect(() => verifyUploadHandle(accessTokenLookalike, 42, AUTH_SECRET)).toThrow( + 'Not an upload handle', + ); + }); +}); diff --git a/packages/mcp-server/test/file-uploads/resolve.test.ts b/packages/mcp-server/test/file-uploads/resolve.test.ts new file mode 100644 index 0000000000..3ca75dfbbf --- /dev/null +++ b/packages/mcp-server/test/file-uploads/resolve.test.ts @@ -0,0 +1,217 @@ +import type { UploadStorage } from '../../src/file-uploads/types'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; + +import * as crypto from 'crypto'; + +import { UPLOADED_FILE_PREFIX } from '../../src/file-uploads/file-reference'; +import { signUploadHandle } from '../../src/file-uploads/handles'; +import resolveUploadedFileValues from '../../src/file-uploads/resolve'; +import { resolveFileUploads } from '../../src/file-uploads/types'; + +const AUTH_SECRET = 'test-auth-secret'; + +const authInfo = { + token: 'token', + clientId: '42', + scopes: ['mcp:action'], + extra: { userId: 42 }, +} as unknown as AuthInfo; + +function makeStorage(overrides: Partial = {}): UploadStorage { + return { + createUploadUrl: jest.fn().mockResolvedValue({ url: 'https://storage.example/put' }), + download: jest.fn().mockResolvedValue(Buffer.from('%PDF-1.4')), + getSize: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeUploads(storage: UploadStorage, options: { maxBytes?: number } = {}) { + return resolveFileUploads({ storage, ...options }, AUTH_SECRET); +} + +function makeHandle(overrides: Partial[0]> = {}): string { + const handle = signUploadHandle( + { + key: 'mcp-uploads/uuid/report.pdf', + name: 'report.pdf', + mimeType: 'application/pdf', + userId: 42, + ...overrides, + }, + AUTH_SECRET, + 60, + ); + + return `${UPLOADED_FILE_PREFIX}${handle}`; +} + +describe('resolveUploadedFileValues', () => { + it('returns the values untouched when none carries a reference', async () => { + const storage = makeStorage(); + const values = { amount: 12, note: 'plain string' }; + + const resolved = await resolveUploadedFileValues(values, authInfo, makeUploads(storage)); + + expect(resolved).toBe(values); + expect(storage.download).not.toHaveBeenCalled(); + }); + + it('throws a configuration error when a reference is present but uploads are disabled', async () => { + await expect( + resolveUploadedFileValues({ document: makeHandle() }, authInfo, undefined), + ).rejects.toThrow('File uploads are not configured on this server'); + }); + + it('throws when there is no authenticated user to bind the reference to', async () => { + await expect( + resolveUploadedFileValues({ document: makeHandle() }, undefined, makeUploads(makeStorage())), + ).rejects.toThrow('Cannot resolve uploaded files without an authenticated user'); + }); + + it('replaces a reference with the uploaded file, leaving the name unencoded', async () => { + const storage = makeStorage(); + + const resolved = await resolveUploadedFileValues( + { document: makeHandle({ name: 'rapport final.pdf' }), note: 'untouched' }, + authInfo, + makeUploads(storage), + ); + + expect(storage.download).toHaveBeenCalledWith('mcp-uploads/uuid/report.pdf'); + expect(resolved.document).toEqual({ + buffer: Buffer.from('%PDF-1.4'), + mimeType: 'application/pdf', + name: 'rapport final.pdf', + }); + expect(resolved.note).toBe('untouched'); + }); + + it('resolves references inside array values and leaves other entries alone', async () => { + const resolved = await resolveUploadedFileValues( + { attachments: [makeHandle(), 'existing-value'] }, + authInfo, + makeUploads(makeStorage()), + ); + + expect(resolved.attachments).toEqual([ + expect.objectContaining({ name: 'report.pdf', mimeType: 'application/pdf' }), + 'existing-value', + ]); + }); + + it('downloads a reference used by several fields only once', async () => { + const storage = makeStorage(); + const handle = makeHandle(); + + const resolved = await resolveUploadedFileValues( + { front: handle, back: handle }, + authInfo, + makeUploads(storage), + ); + + expect(storage.download).toHaveBeenCalledTimes(1); + expect(resolved.front).toBe(resolved.back); + }); + + it('rejects a handle issued to another user', async () => { + await expect( + resolveUploadedFileValues( + { document: makeHandle({ userId: 999 }) }, + authInfo, + makeUploads(makeStorage()), + ), + ).rejects.toThrow('Handle was issued to another user'); + }); + + it('rejects an oversized upload without downloading it when the backend reports a size', async () => { + const storage = makeStorage({ getSize: jest.fn().mockResolvedValue(50 * 1024 * 1024) }); + + await expect( + resolveUploadedFileValues({ document: makeHandle() }, authInfo, makeUploads(storage)), + ).rejects.toThrow('above the 20971520 byte limit'); + + expect(storage.download).not.toHaveBeenCalled(); + }); + + it('rejects an oversized upload after download when the backend cannot report a size', async () => { + const storage = makeStorage({ download: jest.fn().mockResolvedValue(Buffer.alloc(11)) }); + + await expect( + resolveUploadedFileValues( + { document: makeHandle() }, + authInfo, + makeUploads(storage, { maxBytes: 10 }), + ), + ).rejects.toThrow('above the 10 byte limit'); + }); + + it('rejects an empty upload with a hint about the PUT step', async () => { + const storage = makeStorage({ download: jest.fn().mockResolvedValue(Buffer.alloc(0)) }); + + await expect( + resolveUploadedFileValues({ document: makeHandle() }, authInfo, makeUploads(storage)), + ).rejects.toThrow('Uploaded file is empty'); + }); + + it('rejects content that does not match the sha256 the handle was pinned to', async () => { + const pinned = crypto.createHash('sha256').update('original content').digest('base64'); + const storage = makeStorage({ + download: jest.fn().mockResolvedValue(Buffer.from('substituted content')), + }); + + await expect( + resolveUploadedFileValues( + { document: makeHandle({ sha256: pinned }) }, + authInfo, + makeUploads(storage), + ), + ).rejects.toThrow('does not match the sha256 it was pinned to'); + }); + + it('accepts content matching the pinned sha256', async () => { + const content = Buffer.from('original content'); + const pinned = crypto.createHash('sha256').update(new Uint8Array(content)).digest('base64'); + const storage = makeStorage({ download: jest.fn().mockResolvedValue(content) }); + + const resolved = await resolveUploadedFileValues( + { document: makeHandle({ sha256: pinned }) }, + authInfo, + makeUploads(storage), + ); + + expect(resolved.document).toEqual(expect.objectContaining({ buffer: content })); + }); + + it('bounds concurrent downloads to maxConcurrentDownloads', async () => { + let active = 0; + let peak = 0; + + const storage = makeStorage({ + download: jest.fn().mockImplementation(async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise(resolve => { + setTimeout(resolve, 5); + }); + active -= 1; + + return Buffer.from('file content'); + }), + }); + + const uploads = resolveFileUploads({ storage, maxConcurrentDownloads: 2 }, AUTH_SECRET); + + const values = Object.fromEntries( + Array.from({ length: 6 }, (_, i) => [ + `file_${i}`, + makeHandle({ key: `mcp-uploads/uuid/file-${i}.pdf` }), + ]), + ); + + await resolveUploadedFileValues(values, authInfo, uploads); + + expect(storage.download).toHaveBeenCalledTimes(6); + expect(peak).toBeLessThanOrEqual(2); + }); +}); diff --git a/packages/mcp-server/test/file-uploads/routes.test.ts b/packages/mcp-server/test/file-uploads/routes.test.ts new file mode 100644 index 0000000000..0dbf36fcbe --- /dev/null +++ b/packages/mcp-server/test/file-uploads/routes.test.ts @@ -0,0 +1,187 @@ +import type { UploadStorage } from '../../src/file-uploads/types'; +import type { Logger } from '../../src/server'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import type { Express } from 'express'; + +import express from 'express'; +import request from 'supertest'; + +import { verifyUploadHandle } from '../../src/file-uploads/handles'; +import createFilesRouter from '../../src/file-uploads/routes'; +import { resolveFileUploads } from '../../src/file-uploads/types'; + +const AUTH_SECRET = 'test-auth-secret'; +const mockLogger: Logger = jest.fn(); + +const authenticatedUser = { + token: 'token', + clientId: '42', + scopes: ['mcp:action'], + extra: { userId: 42 }, +} as unknown as AuthInfo; + +function makeApp(options: { storage?: UploadStorage; auth?: AuthInfo }): { + app: Express; + storage: UploadStorage; +} { + const storage: UploadStorage = options.storage ?? { + createUploadUrl: jest.fn().mockResolvedValue({ + url: 'https://storage.example/put?signed=1', + headers: { 'Content-Type': 'application/pdf' }, + }), + download: jest.fn(), + getSize: jest.fn(), + }; + + const uploads = resolveFileUploads({ storage }, AUTH_SECRET); + + const app = express(); + app.use(express.json()); + + // Stands in for requireBearerAuth, which attaches the verified AuthInfo to req.auth. + app.use((req, res, next) => { + req.auth = options.auth; + next(); + }); + + app.use('/files', createFilesRouter(uploads, mockLogger)); + + return { app, storage }; +} + +function claimsOf(fileHandle: string) { + return verifyUploadHandle(fileHandle.slice('$uploadedFile:'.length), 42, AUTH_SECRET); +} + +describe('POST /files', () => { + it('returns 401 when no authenticated user is attached to the request', async () => { + const { app } = makeApp({ auth: undefined }); + + const response = await request(app) + .post('/files') + .send({ filename: 'report.pdf', mimeType: 'application/pdf' }); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Missing or invalid access token.' }); + }); + + it('returns 400 when filename is missing or blank', async () => { + const { app } = makeApp({ auth: authenticatedUser }); + + const response = await request(app) + .post('/files') + .send({ filename: ' ', mimeType: 'application/pdf' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'filename is required.' }); + }); + + it('returns 400 when mimeType is not a type/subtype pair', async () => { + const { app } = makeApp({ auth: authenticatedUser }); + + const response = await request(app) + .post('/files') + .send({ filename: 'report.pdf', mimeType: 'not a mime type' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'mimeType is required, e.g. application/pdf.' }); + }); + + it('returns 400 when sha256 is neither hex nor base64', async () => { + const { app } = makeApp({ auth: authenticatedUser }); + + const response = await request(app) + .post('/files') + .send({ filename: 'report.pdf', mimeType: 'application/pdf', sha256: 'nope' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'sha256 must be the file digest as hex or base64.' }); + }); + + it('returns an upload destination and a redeemable handle', async () => { + const { app, storage } = makeApp({ auth: authenticatedUser }); + + const response = await request(app) + .post('/files') + .send({ filename: 'report.pdf', mimeType: 'application/pdf' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + uploadUrl: 'https://storage.example/put?signed=1', + method: 'PUT', + headers: { 'Content-Type': 'application/pdf' }, + expiresInSeconds: 15 * 60, + maxBytes: 20 * 1024 * 1024, + }); + expect(response.body.fileHandle).toMatch(/^\$uploadedFile:/); + expect(response.body.usage).toContain('executeAction'); + + expect(storage.createUploadUrl).toHaveBeenCalledWith({ + key: expect.stringMatching(/^mcp-uploads\/[0-9a-f-]{36}\/report\.pdf$/), + mimeType: 'application/pdf', + expiresInSeconds: 15 * 60, + }); + + expect(claimsOf(response.body.fileHandle)).toMatchObject({ + name: 'report.pdf', + mimeType: 'application/pdf', + }); + }); + + it('normalizes a hex sha256 to base64 and pins both the destination and the handle', async () => { + const { app, storage } = makeApp({ auth: authenticatedUser }); + const hex = 'a'.repeat(64); + const expectedBase64 = Buffer.from(hex, 'hex').toString('base64'); + + const response = await request(app) + .post('/files') + .send({ filename: 'report.pdf', mimeType: 'application/pdf', sha256: hex }); + + expect(response.status).toBe(200); + expect(storage.createUploadUrl).toHaveBeenCalledWith( + expect.objectContaining({ sha256: expectedBase64 }), + ); + expect(claimsOf(response.body.fileHandle).sha256).toBe(expectedBase64); + }); + + it('sanitizes the filename so it cannot escape the storage key', async () => { + const { app, storage } = makeApp({ auth: authenticatedUser }); + + const response = await request(app) + .post('/files') + .send({ filename: '../etc/passwd.pdf', mimeType: 'application/pdf' }); + + expect(response.status).toBe(200); + + const { key } = (storage.createUploadUrl as jest.Mock).mock.calls[0][0]; + expect(key.split('/').pop()).not.toMatch(/[/]/); + expect(key).toMatch(/^mcp-uploads\/[0-9a-f-]{36}\/[\w.\- ()]+$/); + }); + + it('keeps a filename the agent can round-trip, spaces and all', async () => { + const { app } = makeApp({ auth: authenticatedUser }); + + const response = await request(app) + .post('/files') + .send({ filename: 'rapport final (v2).pdf', mimeType: 'application/pdf' }); + + expect(claimsOf(response.body.fileHandle).name).toBe('rapport final (v2).pdf'); + }); + + it('returns 500 without leaking details when the storage backend fails', async () => { + const storage: UploadStorage = { + createUploadUrl: jest.fn().mockRejectedValue(new Error('bucket is on fire')), + download: jest.fn(), + getSize: jest.fn(), + }; + const { app } = makeApp({ auth: authenticatedUser, storage }); + + const response = await request(app) + .post('/files') + .send({ filename: 'report.pdf', mimeType: 'application/pdf' }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Failed to create upload URL.' }); + expect(mockLogger).toHaveBeenCalledWith('Error', expect.stringContaining('bucket is on fire')); + }); +}); diff --git a/packages/mcp-server/test/mcp-paths.test.ts b/packages/mcp-server/test/mcp-paths.test.ts index 398c00bf21..dd422750b0 100644 --- a/packages/mcp-server/test/mcp-paths.test.ts +++ b/packages/mcp-server/test/mcp-paths.test.ts @@ -111,4 +111,26 @@ describe('mcp-paths', () => { expect(matches(url)).toBe(false); }); }); + + describe('with file uploads enabled', () => { + const matches = makeIsMcpRoute('', { fileUploads: true }); + + it('claims /files only when the option is on', () => { + expect(buildMcpPaths('')).not.toContain('/files'); + expect(buildMcpPaths('', { fileUploads: true })).toContain('/files'); + expect(buildMcpPaths('/ai', { fileUploads: true })).toContain('/ai/files'); + }); + + it.each(['/files', '/files?x=1'])('claims %p', url => { + expect(matches(url)).toBe(true); + }); + + it('leaves the host /files route alone when the option is off', () => { + expect(makeIsMcpRoute('')('/files')).toBe(false); + }); + + it('does not shadow a sibling route like /files-admin', () => { + expect(matches('/files-admin')).toBe(false); + }); + }); }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 99e8cd2258..6d94ab8e5f 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -5,6 +5,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; +import { UPLOADED_FILE_PREFIX } from '../../src/file-uploads/file-reference'; +import { signUploadHandle } from '../../src/file-uploads/handles'; +import { resolveFileUploads } from '../../src/file-uploads/types'; import declareExecuteActionTool from '../../src/tools/execute-action'; import { buildClientWithActions } from '../../src/utils/agent-caller'; import withActivityLog from '../../src/utils/with-activity-log'; @@ -552,4 +555,134 @@ describe('declareExecuteActionTool', () => { }); }); }); + + describe('file uploads', () => { + const AUTH_SECRET = 'test-auth-secret'; + + const fileUploads = () => + resolveFileUploads( + { + storage: { + createUploadUrl: jest.fn().mockResolvedValue({ url: 'https://storage.example/put' }), + download: jest.fn().mockResolvedValue(Buffer.from('%PDF-1.4')), + getSize: jest.fn().mockResolvedValue(undefined), + }, + }, + AUTH_SECRET, + ); + + const uploadExtra = { + authInfo: { + token: 'test-token', + extra: { userId: 42, forestServerToken: 'forest-token', renderingId: '123' }, + }, + } as unknown as RequestHandlerExtra; + + const makeHandle = () => + `${UPLOADED_FILE_PREFIX}${signUploadHandle( + { + key: 'mcp-uploads/uuid/report.pdf', + name: 'rapport final.pdf', + mimeType: 'application/pdf', + userId: 42, + }, + AUTH_SECRET, + 60, + )}`; + + const mockAgentAction = () => { + const mockSetFields = jest.fn().mockResolvedValue(undefined); + const mockAction = jest.fn().mockResolvedValue({ + execute: jest.fn().mockResolvedValue({ success: 'Action executed' }), + setFields: mockSetFields, + }); + mockBuildClientWithActions.mockResolvedValue({ + rpcClient: { collection: jest.fn().mockReturnValue({ action: mockAction }) }, + authData: { userId: 42, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + return mockSetFields; + }; + + it('documents the upload workflow in the description when uploads are enabled', () => { + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + fileUploads: fileUploads(), + }); + + expect(registeredToolConfig.description).toContain('POST /files'); + expect(registeredToolConfig.description).toContain('never inline base64'); + }); + + it('does not mention uploads in the description when disabled', () => { + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.description).not.toContain('POST /files'); + }); + + it('hands the uploaded file to agent-client, which owns the encoding', async () => { + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + fileUploads: fileUploads(), + }); + const mockSetFields = mockAgentAction(); + + await registeredToolHandler( + { + collectionName: 'users', + actionName: 'attachDocument', + recordIds: [1], + values: { document: makeHandle(), note: 'untouched' }, + }, + uploadExtra, + ); + + expect(mockSetFields).toHaveBeenCalledWith({ + document: { + buffer: Buffer.from('%PDF-1.4'), + mimeType: 'application/pdf', + name: 'rapport final.pdf', + }, + note: 'untouched', + }); + }); + + it('returns a tool error when a handle is sent but uploads are not configured', async () => { + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + const mockSetFields = mockAgentAction(); + + const result = await registeredToolHandler( + { + collectionName: 'users', + actionName: 'attachDocument', + recordIds: [1], + values: { document: makeHandle() }, + }, + uploadExtra, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining('File uploads are not configured on this server'), + }, + ], + isError: true, + }); + expect(mockSetFields).not.toHaveBeenCalled(); + }); + }); }); From 4c585f34f2cdb5083b6541e212df1b6ad04d7388 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 10 Aug 2026 15:01:27 +0200 Subject: [PATCH 05/39] fix: address the review of the file upload side-channel The PlainField.type widening broke agent-bff and workflow-executor; the wire array form is now normalized in FieldGetter.getType instead, which keeps the public signature a string. loadChanges still echoes the wire shape verbatim, which the agent's change-hook parser requires. Silent failures found in review: - a maxConcurrentDownloads below 1 queued every redemption with nothing left to release it, hanging instead of failing; the numeric options are now validated at startup - getSize answering null or NaN skipped the size cap, since strictNullChecks is off - a single file on a FileList field, and a file on a field that is not one, were shipped unencoded and serialized into the column - parseDataUri raised an opaque TypeError on anything that was not a data uri - resolution ran outside withActivityLog, so a cross-user redemption attempt left no audit trail Also: verify handles before taking a download slot, validate the claims that feed storage.download, pin the JWT algorithm, name the field in redemption errors, reject a filename made only of dots, and plumb fileUploads through mountAiMcpServer so the option is reachable on the embedded mount. The README claim that maxConcurrentDownloads bounds memory was wrong: every file a call references is held until it completes. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/action-fields/action-field.ts | 3 +- .../src/action-fields/field-getter.ts | 8 +- .../src/action-fields/file-value.ts | 42 ++++++--- .../test/action-fields/file-value.test.ts | 64 ++++++++++++- packages/agent/src/agent.ts | 9 +- .../src/utils/forest-schema/action-values.ts | 6 +- .../datasource-toolkit/src/utils/data-uri.ts | 10 +- .../test/utils/data-uri.test.ts | 8 ++ packages/mcp-server/README.md | 8 +- .../src/file-uploads/file-reference.ts | 2 - .../mcp-server/src/file-uploads/handles.ts | 19 ++-- .../mcp-server/src/file-uploads/resolve.ts | 83 ++++++++++------- .../mcp-server/src/file-uploads/routes.ts | 21 +++-- .../mcp-server/src/file-uploads/semaphore.ts | 9 +- packages/mcp-server/src/file-uploads/types.ts | 69 ++++++++------ packages/mcp-server/src/mcp-paths.ts | 9 +- packages/mcp-server/src/server.ts | 8 +- .../mcp-server/src/tools/execute-action.ts | 10 +- .../test/file-uploads/handles.test.ts | 24 ++++- .../test/file-uploads/resolve.test.ts | 86 +++++++++++++++++- .../test/file-uploads/routes.test.ts | 43 +++++++-- .../test/file-uploads/semaphore.test.ts | 91 +++++++++++++++++++ packages/mcp-server/test/server.test.ts | 33 +++++++ .../test/tools/get-action-form.test.ts | 20 ++++ 24 files changed, 546 insertions(+), 139 deletions(-) create mode 100644 packages/mcp-server/test/file-uploads/semaphore.test.ts diff --git a/packages/agent-client/src/action-fields/action-field.ts b/packages/agent-client/src/action-fields/action-field.ts index b93efe3006..cd5e13cfba 100644 --- a/packages/agent-client/src/action-fields/action-field.ts +++ b/packages/agent-client/src/action-fields/action-field.ts @@ -1,5 +1,4 @@ import type FieldFormStates from './field-form-states'; -import type { PlainField } from './types'; export default abstract class ActionField { private readonly fieldsFormStates: FieldFormStates; @@ -18,7 +17,7 @@ export default abstract class ActionField { return this.name; } - getType(): PlainField['type'] { + getType(): string { return this.field?.getType(); } diff --git a/packages/agent-client/src/action-fields/field-getter.ts b/packages/agent-client/src/action-fields/field-getter.ts index bee051c5c5..a66942a155 100644 --- a/packages/agent-client/src/action-fields/field-getter.ts +++ b/packages/agent-client/src/action-fields/field-getter.ts @@ -19,7 +19,11 @@ export default class FieldGetter { return this.plainField.field; } - getType(): PlainField['type'] { - return this.plainField.type; + // Agents emit list types as ['File'] / ['String'] but loadChanges echoes plainField back to + // them verbatim, so the wire shape is normalized here for dispatch rather than in place. + getType(): string { + const { type } = this.plainField; + + return Array.isArray(type) ? `${type[0]}List` : type; } } diff --git a/packages/agent-client/src/action-fields/file-value.ts b/packages/agent-client/src/action-fields/file-value.ts index c6ffa20231..9f58c54b9d 100644 --- a/packages/agent-client/src/action-fields/file-value.ts +++ b/packages/agent-client/src/action-fields/file-value.ts @@ -1,14 +1,13 @@ -import type { PlainField } from './types'; import type { File } from '@forestadmin/datasource-toolkit'; import { makeDataUri } from '@forestadmin/datasource-toolkit'; -export function isFileType(type: PlainField['type']): boolean { +function isFileType(type: string): boolean { return type === 'File'; } -export function isFileListType(type: PlainField['type']): boolean { - return type === 'FileList' || (Array.isArray(type) && type[0] === 'File'); +function isFileListType(type: string): boolean { + return type === 'FileList'; } function isFile(value: unknown): value is File { @@ -16,34 +15,53 @@ function isFile(value: unknown): value is File { return ( typeof value === 'object' && + value !== null && Buffer.isBuffer(candidate.buffer) && typeof candidate.mimeType === 'string' ); } +function fileError(fieldName: string, detail: string): Error { + return new Error(`Field "${fieldName}" ${detail}`); +} + function encodeFileValue(value: unknown, fieldName: string): unknown { - // Strings pass through untouched: an already encoded data uri stays byte-identical, and - // callers that address the file indirectly (mcp-server upload handles) keep their sentinel. - if (value === null || value === undefined || typeof value === 'string') return value; + if (value === null || value === undefined) return value; + + // Callers that address the file indirectly (mcp-server upload handles) keep their sentinel: + // validating strings here would break them, and the agent owns the final validation. + if (typeof value === 'string') return value; if (isFile(value)) return makeDataUri(value); - throw new Error( - `Field "${fieldName}" expects a file: pass { buffer, mimeType, name } ` + - 'or a string holding an already encoded data uri.', + throw fileError( + fieldName, + 'expects a file: pass { buffer, mimeType, name } or a string holding a data uri.', ); } export default function encodeFileFieldValue( - type: PlainField['type'], + type: string, value: unknown, fieldName: string, ): unknown { if (isFileListType(type)) { - return Array.isArray(value) ? value.map(item => encodeFileValue(item, fieldName)) : value; + if (value === null || value === undefined) return value; + + if (!Array.isArray(value)) { + throw fileError(fieldName, 'expects a list of files: pass an array.'); + } + + return value.map(item => encodeFileValue(item, fieldName)); } if (isFileType(type)) return encodeFileValue(value, fieldName); + // A file reaching a field that is not declared as one is never intentional, and it would be + // JSON-serialized into the column as {"buffer":{"type":"Buffer",...}} without any error. + if (isFile(value)) { + throw fileError(fieldName, `is a ${type} field and cannot hold a file.`); + } + return value; } diff --git a/packages/agent-client/test/action-fields/file-value.test.ts b/packages/agent-client/test/action-fields/file-value.test.ts index c21bf43e2e..18bd0750f0 100644 --- a/packages/agent-client/test/action-fields/file-value.test.ts +++ b/packages/agent-client/test/action-fields/file-value.test.ts @@ -107,7 +107,7 @@ describe('file values in action forms', () => { await expect(fieldFormStates.setFieldValue('document', value)).rejects.toThrow( 'Field "document" expects a file: pass { buffer, mimeType, name } ' + - 'or a string holding an already encoded data uri.', + 'or a string holding a data uri.', ); }); }); @@ -123,22 +123,76 @@ describe('file values in action forms', () => { }); }); - it('leaves a non-array value untouched', async () => { + it('leaves null untouched', async () => { await setupFields([{ field: 'attachments', type: ['File'] }]); await fieldFormStates.setFieldValue('attachments', null); expect(fieldFormStates.getFieldValues()).toEqual({ attachments: null }); }); + + it('rejects a single file where a list is expected, instead of shipping it unencoded', async () => { + await setupFields([{ field: 'attachments', type: ['File'] }]); + + await expect(fieldFormStates.setFieldValue('attachments', pdf)).rejects.toThrow( + 'Field "attachments" expects a list of files: pass an array.', + ); + }); + + it('rejects an invalid item inside the list', async () => { + await setupFields([{ field: 'attachments', type: ['File'] }]); + + await expect(fieldFormStates.setFieldValue('attachments', [{ foo: 1 }])).rejects.toThrow( + 'Field "attachments" expects a file', + ); + }); }); describe('on a field that is not a file', () => { - it('does not encode, because the declared type governs', async () => { + it('leaves ordinary values alone', async () => { + await setupFields([{ field: 'comment', type: 'String' }]); + + await fieldFormStates.setFieldValue('comment', 'plain text'); + + expect(fieldFormStates.getFieldValues()).toEqual({ comment: 'plain text' }); + }); + + // A resolved upload landing on the wrong field would otherwise be JSON-serialized into the + // column as {"buffer":{"type":"Buffer",...}} with no error at any layer. + it('rejects a file, which would otherwise be serialized into the column', async () => { await setupFields([{ field: 'comment', type: 'String' }]); - await fieldFormStates.setFieldValue('comment', pdf); + await expect(fieldFormStates.setFieldValue('comment', pdf)).rejects.toThrow( + 'Field "comment" is a String field and cannot hold a file.', + ); + }); + }); + + describe('type normalization', () => { + it('exposes the wire array form as a canonical list type', async () => { + await setupFields([{ field: 'attachments', type: ['File'] }]); + + expect(fieldFormStates.getField('attachments')?.getType()).toBe('FileList'); + }); + + it('keeps the wire form in the payload echoed back to the agent', async () => { + await setupFields([{ field: 'attachments', type: ['File'], hook: 'changeHook' }]); + httpRequester.query.mockResolvedValue({ fields: [], layout: [] }); + + await fieldFormStates.setFieldValue('attachments', [pdf]); - expect(fieldFormStates.getFieldValues()).toEqual({ comment: pdf }); + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + data: { + attributes: expect.objectContaining({ + fields: [expect.objectContaining({ type: ['File'] })], + }), + type: 'custom-action-hook-requests', + }, + }, + }), + ); }); }); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 4764ed97f3..e3461977f4 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -17,7 +17,7 @@ import type { } from '@forestadmin/datasource-customizer'; import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; -import type { TokenTtlOptions, ToolName } from '@forestadmin/mcp-server'; +import type { FileUploadsOptions, TokenTtlOptions, ToolName } from '@forestadmin/mcp-server'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; import bodyParser from '@koa/bodyparser'; @@ -57,6 +57,7 @@ export default class Agent extends FrameworkMounter private mcpBasePath?: string; private mcpTokenTtl?: TokenTtlOptions; private mcpAllowedOAuthClients?: string[]; + private mcpFileUploads?: FileUploadsOptions; /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; @@ -264,18 +265,23 @@ export default class Agent extends FrameworkMounter * // Example: only accept approved OAuth client applications, matched by the domain of their * // registered redirect URIs (subdomains included). Other clients get invalid_client. * agent.mountAiMcpServer({ allowedOAuthClients: ['dust.tt'] }); + * // Example: let action File fields be filled over MCP. Experimental, and it needs a storage + * // backend you provide: see the mcp-server README for the UploadStorage contract. + * agent.mountAiMcpServer({ fileUploads: { storage } }); */ mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string; tokenTtl?: TokenTtlOptions; allowedOAuthClients?: string[]; + fileUploads?: FileUploadsOptions; }): this { this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; this.mcpBasePath = options?.basePath; this.mcpTokenTtl = options?.tokenTtl; this.mcpAllowedOAuthClients = options?.allowedOAuthClients; + this.mcpFileUploads = options?.fileUploads; return this; } @@ -396,6 +402,7 @@ export default class Agent extends FrameworkMounter basePath: this.mcpBasePath, tokenTtl: this.mcpTokenTtl, allowedOAuthClients: this.mcpAllowedOAuthClients, + fileUploads: this.mcpFileUploads, agentDispatcher: this.getInProcessDispatcher(), }); diff --git a/packages/agent/src/utils/forest-schema/action-values.ts b/packages/agent/src/utils/forest-schema/action-values.ts index d617ba9bcc..495a53c139 100644 --- a/packages/agent/src/utils/forest-schema/action-values.ts +++ b/packages/agent/src/utils/forest-schema/action-values.ts @@ -58,9 +58,11 @@ export default class ForestValueConverter { const collection = dataSource.getCollection(collectionName); data[field.field] = IdUtils.unpackId(collection.schema, field.value as string); } else if (field.type === 'File') { - data[field.field] = parseDataUri(field.value as string); + data[field.field] = isDataUri(field.value) ? parseDataUri(field.value) : field.value; } else if (Array.isArray(field.type) && field.type[0] === 'File') { - data[field.field] = (field.value as string[])?.map(v => parseDataUri(v)); + data[field.field] = (field.value as string[])?.map(v => + isDataUri(v) ? parseDataUri(v) : v, + ); } else { data[field.field] = field.value; } diff --git a/packages/datasource-toolkit/src/utils/data-uri.ts b/packages/datasource-toolkit/src/utils/data-uri.ts index 92c5210014..ccab81acdf 100644 --- a/packages/datasource-toolkit/src/utils/data-uri.ts +++ b/packages/datasource-toolkit/src/utils/data-uri.ts @@ -4,8 +4,6 @@ export function isDataUri(value: unknown): value is string { return typeof value === 'string' && value.startsWith('data:'); } -// Hand-rolled rather than a spec-compliant parser: Forest carries the filename in a -// non-standard `name=` media type that RFC 2397 does not define, and spec parsers drop it. export function makeDataUri(file: File): string { if (!file) return null; @@ -20,9 +18,17 @@ export function makeDataUri(file: File): string { return `${header};base64,${buffer.toString('base64')}`; } +// Hand-rolled rather than a spec-compliant parser: Forest carries the filename in a +// non-standard `name=` media type that RFC 2397 does not define, and spec parsers drop it. export function parseDataUri(dataUri: string): File { if (!dataUri) return null; + // Without this the split below yields undefined data and Buffer.from raises an opaque + // TypeError. Reachable from action values, which a model can populate freely. + if (!dataUri.startsWith('data:')) { + throw new Error(`Not a data uri: "${dataUri.slice(0, 32)}"`); + } + const [header, data] = dataUri.substring(5).split(','); const [mimeType, ...mediaTypes] = header.split(';'); const result = { mimeType, buffer: Buffer.from(data, 'base64') }; diff --git a/packages/datasource-toolkit/test/utils/data-uri.test.ts b/packages/datasource-toolkit/test/utils/data-uri.test.ts index 5d24f153a0..8b9eae8837 100644 --- a/packages/datasource-toolkit/test/utils/data-uri.test.ts +++ b/packages/datasource-toolkit/test/utils/data-uri.test.ts @@ -82,6 +82,14 @@ describe('DataUri', () => { it('returns null when there is no data uri', () => { expect(parseDataUri(null)).toBeNull(); }); + + it.each([ + ['a plain filename', 'report.pdf'], + ['an upload sentinel', '$uploadedFile:eyJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ.sig'], + ['an http url', 'https://example.com/f.pdf'], + ])('rejects %s with a readable message instead of a TypeError', (_, value) => { + expect(() => parseDataUri(value)).toThrow('Not a data uri'); + }); }); describe('round trip', () => { diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 2ed803ff39..d08571b0c3 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -180,6 +180,8 @@ the conversation: 2. The client uploads the raw bytes directly to the storage backend, so they never pass through the MCP server or the model. 3. The client passes the handle (`"$uploadedFile:<...>"`) as the field value in `executeAction`. The server downloads the object and hands it to the agent. The model only ever exchanges the small handle. +It is available on the embedded mount too: `agent.mountAiMcpServer({ fileUploads: { storage } })`. + ```mermaid sequenceDiagram participant Client as MCP client @@ -260,12 +262,14 @@ A few properties matter in production. - The server stays stateless. The handle is a JWT signed with `authSecret`, so there is no database and no session affinity, and any replica can redeem a handle issued by another. - A handle is bound to the user it was issued to. Only that user's Bearer token can redeem it, and it expires with `handleTtlSeconds`. It stays redeemable until then, so keep the TTL short. - When the client sends `sha256` (hex or base64), the upload URL is pinned to that digest and the digest is checked again on the downloaded bytes at redemption. Content substituted after an upload URL leak cannot be redeemed. -- **`maxBytes` does not bound memory on its own.** A pre-authorized upload URL cannot always cap the object size, so the limit is enforced at redemption: before downloading when `getSize` reports a size, and only after the bytes are in memory when it returns `undefined`. Implement `getSize` whenever the backend can answer it cheaply. `maxConcurrentDownloads` bounds how many redemptions hold a file at once. +- **`maxBytes` does not bound memory on its own.** A pre-authorized upload URL cannot always cap the object size, so the limit is enforced at redemption: before downloading when `getSize` reports a size, and only after the bytes are in memory when it returns `undefined`. Implement `getSize` whenever the backend can answer it cheaply. +- **`maxConcurrentDownloads` bounds concurrent downloads, not peak memory.** All the files one `executeAction` call references are held together until the call completes, so a form with N file fields holds up to N × `maxBytes` whatever the concurrency limit is. Size `maxBytes` against the number of file fields your actions declare. - The server never deletes objects. Configure a lifecycle rule on the storage backend, for example deleting objects under `keyPrefix` after one day. Only `executeAction` resolves handles. `getActionForm` echoes field values back to the model, so a handle stays a handle there: resolving it would put the file content back into the model's context. -A file field that declares a change hook therefore sends the unresolved handle to that hook. +A file field that declares a change hook therefore sends the unresolved handle to that hook, which +receives it as a plain string rather than a parsed file. ## API Endpoints diff --git a/packages/mcp-server/src/file-uploads/file-reference.ts b/packages/mcp-server/src/file-uploads/file-reference.ts index 47b99403de..be364f13a8 100644 --- a/packages/mcp-server/src/file-uploads/file-reference.ts +++ b/packages/mcp-server/src/file-uploads/file-reference.ts @@ -9,8 +9,6 @@ export const UPLOADED_FILE_PREFIX = '$uploadedFile:'; export type FileReference = { kind: 'uploadHandle'; handle: string }; /** - * Recognizes the values that stand for a file the server must fetch itself. - * * Isolated so that the file URIs the MCP specification is designing (SEP-2631) can be added * as another kind without touching the resolution path. */ diff --git a/packages/mcp-server/src/file-uploads/handles.ts b/packages/mcp-server/src/file-uploads/handles.ts index 3f94a3bcb8..005d3f00d2 100644 --- a/packages/mcp-server/src/file-uploads/handles.ts +++ b/packages/mcp-server/src/file-uploads/handles.ts @@ -6,8 +6,7 @@ export interface UploadHandleClaims { key: string; name: string; mimeType: string; - /** Base64 sha256 the upload was pinned to, when the client provided one. */ - sha256?: string; + sha256Base64?: string; } export function signUploadHandle( @@ -22,20 +21,19 @@ export function signUploadHandle( name: claims.name, mime: claims.mimeType, uploader: String(claims.userId), - ...(claims.sha256 && { sha256: claims.sha256 }), + ...(claims.sha256Base64 && { sha256: claims.sha256Base64 }), }, authSecret, { expiresIn: ttlSeconds }, ); } -/** Throws on tampered, expired, or cross-user handles. */ export function verifyUploadHandle( handle: string, userId: number | string, authSecret: string, ): UploadHandleClaims { - const decoded = jsonwebtoken.verify(handle, authSecret) as { + const decoded = jsonwebtoken.verify(handle, authSecret, { algorithms: ['HS256'] }) as { type?: string; key: string; name: string; @@ -49,10 +47,19 @@ export function verifyUploadHandle( if (decoded?.type !== HANDLE_TYPE) throw new Error('Not an upload handle'); if (decoded.uploader !== String(userId)) throw new Error('Handle was issued to another user'); + // key feeds storage.download and mime lands in the stored file, so neither may be undefined. + if ( + typeof decoded.key !== 'string' || + typeof decoded.name !== 'string' || + typeof decoded.mime !== 'string' + ) { + throw new Error('Malformed upload handle'); + } + return { key: decoded.key, name: decoded.name, mimeType: decoded.mime, - sha256: decoded.sha256, + sha256Base64: decoded.sha256, }; } diff --git a/packages/mcp-server/src/file-uploads/resolve.ts b/packages/mcp-server/src/file-uploads/resolve.ts index 87a3ebf4c0..92f4cf944b 100644 --- a/packages/mcp-server/src/file-uploads/resolve.ts +++ b/packages/mcp-server/src/file-uploads/resolve.ts @@ -1,3 +1,4 @@ +import type { FileReference } from './file-reference'; import type { ResolvedFileUploads } from './types'; import type { File } from '@forestadmin/agent-client'; import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; @@ -7,55 +8,62 @@ import * as crypto from 'crypto'; import parseFileReference from './file-reference'; import { verifyUploadHandle } from './handles'; -function collectReferences(values: Record): Set { - const references = new Set(); +// Keyed by reference so one used by several fields is downloaded once; the value is the first +// field that mentioned it, which is what error messages name. +function collectReferences(values: Record): Map { + const references = new Map(); - for (const value of Object.values(values)) { + for (const [field, value] of Object.entries(values)) { const candidates = Array.isArray(value) ? value : [value]; candidates.forEach(candidate => { - if (parseFileReference(candidate)) references.add(candidate as string); + if (parseFileReference(candidate) && !references.has(candidate as string)) { + references.set(candidate as string, field); + } }); } return references; } -async function loadFile( - reference: string, - userId: number | string, +async function download( + field: string, + reference: FileReference, + claims: ReturnType, uploads: ResolvedFileUploads, ): Promise { - const { handle } = parseFileReference(reference); - const claims = verifyUploadHandle(handle, userId, uploads.authSecret); + const tooLarge = (bytes: number) => + new Error( + `Field "${field}": uploaded file is ${bytes} bytes, above the ${uploads.maxBytes} byte limit`, + ); // A pre-authorized upload URL cannot always cap the object size, so the limit is enforced // here, before the bytes are read whenever the backend can report a size. const size = await uploads.storage.getSize(claims.key); - if (size !== undefined && size > uploads.maxBytes) { - throw new Error(`Uploaded file is ${size} bytes, above the ${uploads.maxBytes} byte limit`); + if (typeof size === 'number' && Number.isFinite(size) && size > uploads.maxBytes) { + throw tooLarge(size); } const buffer = await uploads.storage.download(claims.key); if (buffer.length === 0) { - throw new Error('Uploaded file is empty. Did the upload to uploadUrl succeed?'); - } - - if (buffer.length > uploads.maxBytes) { throw new Error( - `Uploaded file is ${buffer.length} bytes, above the ${uploads.maxBytes} byte limit`, + `Field "${field}": uploaded file is empty. Did the upload to uploadUrl succeed?`, ); } - // Even if the upload URL leaked and someone overwrote the object, substituted content - // cannot be redeemed. - if (claims.sha256) { + // Re-checked after download because getSize is advisory, and because the object can be + // replaced between the two calls. + if (buffer.length > uploads.maxBytes) throw tooLarge(buffer.length); + + // Even if the upload URL leaked and someone overwrote the object, content substituted after + // the client pinned a digest cannot be redeemed. + if (claims.sha256Base64) { const digest = crypto.createHash('sha256').update(new Uint8Array(buffer)).digest('base64'); - if (digest !== claims.sha256) { - throw new Error('Uploaded file does not match the sha256 it was pinned to'); + if (digest !== claims.sha256Base64) { + throw new Error(`Field "${field}": uploaded file does not match the sha256 it was pinned to`); } } @@ -63,14 +71,8 @@ async function loadFile( } /** - * Replaces the file references in action form values with the uploaded objects. agent-client - * encodes them for the agent, so the model only ever exchanges the small reference. - * - * References are resolved concurrently and deduplicated, so one referenced by several fields - * is downloaded once. - * - * Only executeAction resolves them. getActionForm echoes field values back to the model, and - * a resolved file there would put the content back into the model's context. + * Only executeAction resolves references. getActionForm echoes field values back to the model, + * and a resolved file there would put the content back into the model's context. */ export default async function resolveUploadedFileValues( values: Record, @@ -94,12 +96,29 @@ export default async function resolveUploadedFileValues( throw new Error('Cannot resolve uploaded files without an authenticated user'); } + // Verified before acquiring a download slot: it is pure CPU, and it gates everything + // expensive, so a batch of forged handles is rejected instead of queueing behind the limit. + const verified = [...references].map(([reference, field]) => { + const parsed = parseFileReference(reference); + + if (parsed.kind !== 'uploadHandle') { + throw new Error(`Field "${field}": unsupported file reference`); + } + + return { + field, + reference, + parsed, + claims: verifyUploadHandle(parsed.handle, userId, uploads.authSecret), + }; + }); + const files = new Map( await Promise.all( - [...references].map( - async (reference): Promise<[string, File]> => [ + verified.map( + async ({ field, reference, parsed, claims }): Promise<[string, File]> => [ reference, - await uploads.limitDownload(() => loadFile(reference, userId, uploads)), + await uploads.limitDownload(() => download(field, parsed, claims, uploads)), ], ), ), diff --git a/packages/mcp-server/src/file-uploads/routes.ts b/packages/mcp-server/src/file-uploads/routes.ts index a06ade40cc..32a85c86de 100644 --- a/packages/mcp-server/src/file-uploads/routes.ts +++ b/packages/mcp-server/src/file-uploads/routes.ts @@ -13,17 +13,19 @@ const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; const SHA256_BASE64_PATTERN = /^[A-Za-z0-9+/]{43}=$/; const MAX_FILENAME_LENGTH = 128; -// The storage key delimits segments with '/', so keep a conservative charset. The data uri -// encoding is agent-client's job and percent-encodes whatever survives here. +// The storage key delimits segments with '/', so keep a conservative charset. Truncation keeps +// the tail so the extension survives. A name made only of dots would let a backend that joins +// paths climb out of the per-upload directory. function sanitizeFilename(filename: string): string { - return filename + const safe = filename .trim() .slice(-MAX_FILENAME_LENGTH) .replace(/[^\w.\- ()]/g, '_'); + + return /^\.+$/.test(safe) ? 'file' : safe; } -// Accepts the digest as hex (shasum -a 256 output) or base64. Returns base64, null when the -// digest is absent, or false when it is malformed. +// Accepts the digest as hex (shasum -a 256 output) or base64. function normalizeSha256(sha256: unknown): string | null | false { if (sha256 === undefined || sha256 === null || sha256 === '') return null; if (typeof sha256 !== 'string') return false; @@ -34,8 +36,6 @@ function normalizeSha256(sha256: unknown): string | null | false { } /** - * Router for POST /files, the upload half of the action file side-channel. - * * Must be mounted behind requireBearerAuth so req.auth carries the caller's identity: the * returned handle is bound to that user and can only be redeemed by them. */ @@ -89,7 +89,7 @@ export default function createFilesRouter(uploads: ResolvedFileUploads, logger: name: safeName, mimeType, userId: userId as number | string, - ...(sha256Base64 && { sha256: sha256Base64 }), + ...(sha256Base64 && { sha256Base64 }), }, uploads.authSecret, uploads.handleTtlSeconds, @@ -112,6 +112,11 @@ export default function createFilesRouter(uploads: ResolvedFileUploads, logger: // eslint-disable-next-line @typescript-eslint/no-unused-vars -- error handlers need arity 4 router.use((error: Error, req: Request, res: Response, next: express.NextFunction) => { logger('Error', `/files error: ${error.message}`); + if (error.stack) logger('Error', `Stack: ${error.stack}`); + + // The chain also carries requireBearerAuth, which answers by itself. + if (res.headersSent) return; + res.status(500).json({ error: 'Failed to create upload URL.' }); }); diff --git a/packages/mcp-server/src/file-uploads/semaphore.ts b/packages/mcp-server/src/file-uploads/semaphore.ts index 88fa7bdb29..052f9240b3 100644 --- a/packages/mcp-server/src/file-uploads/semaphore.ts +++ b/packages/mcp-server/src/file-uploads/semaphore.ts @@ -1,11 +1,8 @@ export type RunExclusive = (task: () => Promise) => Promise; -/** - * Bounds concurrent handle redemptions. Each redemption can hold up to maxBytes plus its - * base64 copy in memory, so the process's worst case stays bounded by the limit instead of - * by whatever load arrives. - */ -export default function createSemaphore(limit: number): RunExclusive { +export default function createSemaphore(rawLimit: number): RunExclusive { + // A limit below 1 would queue every task with nothing left to release it, hanging forever. + const limit = Math.max(1, rawLimit); let active = 0; const queue: Array<() => void> = []; diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index 35bb23583c..36a171e5f3 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -1,26 +1,20 @@ import type { RunExclusive } from './semaphore'; +import type { Logger } from '../server'; import createSemaphore from './semaphore'; -/** - * Storage backend for the action file upload side-channel. - * - * Implementations are provided by the host application (S3, GCS, Azure...). The MCP server - * never sees the file bytes during upload: clients PUT them straight to the URL returned by - * createUploadUrl, and the server only reads them back when an executeAction call redeems - * the handle. - */ +/** Storage backend for the action file upload side-channel. See the README for the flow. */ export interface UploadStorage { - /** Return a pre-authorized URL the client can upload a single object to. */ + /** The URL must be reachable by the MCP client, which is what uploads the bytes. */ createUploadUrl(params: { key: string; mimeType: string; - /** Base64 sha256 digest the upload must match, when the client pinned one. */ + /** Advisory: the server re-verifies the digest after download regardless. */ sha256?: string; expiresInSeconds: number; }): Promise<{ url: string; method?: string; headers?: Record }>; - /** Read the uploaded object back. Must reject when the object does not exist. */ + /** Must reject when the object does not exist. */ download(key: string): Promise; /** @@ -32,29 +26,17 @@ export interface UploadStorage { } /** - * Options for the `fileUploads` server option. - * * @experimental The MCP specification is still designing its own file transfer story * (SEP-2631). The storage contract is expected to survive, but the `POST /files` route and * the handle format may change to follow the specification once it lands. */ export interface FileUploadsOptions { storage: UploadStorage; - /** Key prefix for uploaded objects. Defaults to 'mcp-uploads/'. */ keyPrefix?: string; - /** Lifetime of the upload URL. Defaults to 15 minutes. */ uploadUrlTtlSeconds?: number; - /** - * Lifetime of the file handle. Defaults to 45 minutes, longer than the upload URL, so a - * slow upload still leaves time to run the action. - */ + /** Must stay longer than uploadUrlTtlSeconds, so a slow upload leaves time to run the action. */ handleTtlSeconds?: number; - /** Maximum uploaded file size, enforced when the handle is redeemed. Defaults to 20 MiB. */ maxBytes?: number; - /** - * Maximum handle redemptions running at once per process. Each redemption may hold up to - * maxBytes plus its base64 copy in memory, so this bounds the worst case. Defaults to 5. - */ maxConcurrentDownloads?: number; } @@ -74,21 +56,52 @@ const DEFAULT_HANDLE_TTL_SECONDS = 45 * 60; const DEFAULT_MAX_BYTES = 20 * 1024 * 1024; const DEFAULT_MAX_CONCURRENT_DOWNLOADS = 5; +function positiveInteger(field: keyof FileUploadsOptions, value: number | undefined): number { + if (value === undefined) return undefined; + + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Invalid fileUploads.${field} "${value}": it must be a positive integer.`); + } + + return value; +} + export function resolveFileUploads( options: FileUploadsOptions | undefined, authSecret: string, + logger?: Logger, ): ResolvedFileUploads | undefined { if (!options) return undefined; + if (!options.storage) throw new Error('fileUploads.storage is required.'); + + const uploadUrlTtlSeconds = + positiveInteger('uploadUrlTtlSeconds', options.uploadUrlTtlSeconds) ?? + DEFAULT_UPLOAD_URL_TTL_SECONDS; + const handleTtlSeconds = + positiveInteger('handleTtlSeconds', options.handleTtlSeconds) ?? DEFAULT_HANDLE_TTL_SECONDS; + + // A handle expiring before its upload URL means uploads succeed and every redemption then + // fails with a bare "jwt expired". + if (handleTtlSeconds < uploadUrlTtlSeconds) { + logger?.( + 'Warn', + `fileUploads.handleTtlSeconds=${handleTtlSeconds} is shorter than ` + + `fileUploads.uploadUrlTtlSeconds=${uploadUrlTtlSeconds}: a slow upload will finish with ` + + 'an already expired handle.', + ); + } + return { storage: options.storage, keyPrefix: options.keyPrefix ?? DEFAULT_KEY_PREFIX, - uploadUrlTtlSeconds: options.uploadUrlTtlSeconds ?? DEFAULT_UPLOAD_URL_TTL_SECONDS, - handleTtlSeconds: options.handleTtlSeconds ?? DEFAULT_HANDLE_TTL_SECONDS, - maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, + uploadUrlTtlSeconds, + handleTtlSeconds, + maxBytes: positiveInteger('maxBytes', options.maxBytes) ?? DEFAULT_MAX_BYTES, authSecret, limitDownload: createSemaphore( - options.maxConcurrentDownloads ?? DEFAULT_MAX_CONCURRENT_DOWNLOADS, + positiveInteger('maxConcurrentDownloads', options.maxConcurrentDownloads) ?? + DEFAULT_MAX_CONCURRENT_DOWNLOADS, ), }; } diff --git a/packages/mcp-server/src/mcp-paths.ts b/packages/mcp-server/src/mcp-paths.ts index 3d87278282..f4227c40c1 100644 --- a/packages/mcp-server/src/mcp-paths.ts +++ b/packages/mcp-server/src/mcp-paths.ts @@ -22,15 +22,14 @@ export function normalizeMountPath(input?: string): string { return collapsed; } -/** - * Well-known paths stay anchored at the origin root (per RFC 8414/9728) but carry the prefix - * as a suffix, so a host's own root OAuth metadata is not claimed. - */ export interface McpRouteOptions { - /** Claim the /files upload route too. Only set when the fileUploads option is enabled. */ fileUploads?: boolean; } +/** + * Well-known paths stay anchored at the origin root (per RFC 8414/9728) but carry the prefix + * as a suffix, so a host's own root OAuth metadata is not claimed. + */ export function buildMcpPaths(prefix = '', options: McpRouteOptions = {}): string[] { const normalized = normalizeMountPath(prefix); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 04d268af17..3e3c8e03f9 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -160,10 +160,8 @@ export interface ForestMCPServerOptions { /** * Enables file fields in action forms through an upload side-channel. Without it, action * file fields are unusable over MCP: the agent expects them as data uris, which would - * transit the model's context window and exceed most clients' payload limits. When set, - * POST /files returns a pre-authorized upload URL plus a signed handle, and executeAction - * swaps "$uploadedFile:" values for the uploaded file before calling the agent, so - * the model only ever exchanges the small handle. Requires a storage backend. + * transit the model's context window and exceed most clients' payload limits. + * See the README for the flow and the storage contract. * * @experimental Expected to change to follow the MCP file transfer specification once it * lands (SEP-2631). @@ -448,7 +446,7 @@ export default class ForestMCPServer { async buildExpressApp(baseUrl?: URL): Promise { const { envSecret, authSecret } = this.ensureSecretsAreSet(); - this.fileUploads = resolveFileUploads(this.fileUploadsOptions, authSecret); + this.fileUploads = resolveFileUploads(this.fileUploadsOptions, authSecret, this.logger); await this.fetchCollectionNames(); diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index ef82b61fc5..f66c4aa326 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -61,12 +61,6 @@ To fill a file field, never inline base64 file content. Request an upload destin // Cast to satisfy the type system - the API accepts both string[] and number[] const recordIds = (options.recordIds ?? []) as string[] | number[]; - // Swap the upload handles for the uploaded files before they reach the form. agent-client - // encodes them for the agent. No-op when no value carries a handle. - const values = options.values - ? await resolveUploadedFileValues(options.values, extra.authInfo, ctx.fileUploads) - : undefined; - return withActivityLog({ forestServerClient, request: extra, @@ -78,6 +72,10 @@ To fill a file field, never inline base64 file content. Request an upload destin }, logger, operation: async () => { + const values = options.values + ? await resolveUploadedFileValues(options.values, extra.authInfo, ctx.fileUploads) + : undefined; + const action = await rpcClient .collection(options.collectionName) .action(options.actionName, { recordIds }); diff --git a/packages/mcp-server/test/file-uploads/handles.test.ts b/packages/mcp-server/test/file-uploads/handles.test.ts index a245701a5d..b381b3f8e1 100644 --- a/packages/mcp-server/test/file-uploads/handles.test.ts +++ b/packages/mcp-server/test/file-uploads/handles.test.ts @@ -19,14 +19,14 @@ describe('upload handles', () => { key: 'mcp-uploads/uuid/report.pdf', name: 'report.pdf', mimeType: 'application/pdf', - sha256: undefined, + sha256Base64: undefined, }); }); it('carries the sha256 pin when provided', () => { - const handle = signUploadHandle({ ...claims, sha256: 'digest==' }, AUTH_SECRET, 60); + const handle = signUploadHandle({ ...claims, sha256Base64: 'digest==' }, AUTH_SECRET, 60); - expect(verifyUploadHandle(handle, 42, AUTH_SECRET).sha256).toBe('digest=='); + expect(verifyUploadHandle(handle, 42, AUTH_SECRET).sha256Base64).toBe('digest=='); }); it('accepts a user id whose type differs between signing and redemption', () => { @@ -62,4 +62,22 @@ describe('upload handles', () => { 'Not an upload handle', ); }); + + it.each([ + ['garbage', 'not.a.jwt'], + ['a truncated jwt', 'eyJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ'], + ['an empty string', ''], + ])('rejects %s, which is what a hallucinating model sends', (_, handle) => { + expect(() => verifyUploadHandle(handle, 42, AUTH_SECRET)).toThrow(); + }); + + it('rejects a handle whose key claim is missing, instead of downloading undefined', () => { + const forged = jsonwebtoken.sign( + { type: 'mcp-upload', uploader: '42', name: 'x.pdf', mime: 'application/pdf' }, + AUTH_SECRET, + { expiresIn: 60 }, + ); + + expect(() => verifyUploadHandle(forged, 42, AUTH_SECRET)).toThrow('Malformed upload handle'); + }); }); diff --git a/packages/mcp-server/test/file-uploads/resolve.test.ts b/packages/mcp-server/test/file-uploads/resolve.test.ts index 3ca75dfbbf..9f72ddca66 100644 --- a/packages/mcp-server/test/file-uploads/resolve.test.ts +++ b/packages/mcp-server/test/file-uploads/resolve.test.ts @@ -151,7 +151,7 @@ describe('resolveUploadedFileValues', () => { await expect( resolveUploadedFileValues({ document: makeHandle() }, authInfo, makeUploads(storage)), - ).rejects.toThrow('Uploaded file is empty'); + ).rejects.toThrow('Field "document": uploaded file is empty'); }); it('rejects content that does not match the sha256 the handle was pinned to', async () => { @@ -162,11 +162,11 @@ describe('resolveUploadedFileValues', () => { await expect( resolveUploadedFileValues( - { document: makeHandle({ sha256: pinned }) }, + { document: makeHandle({ sha256Base64: pinned }) }, authInfo, makeUploads(storage), ), - ).rejects.toThrow('does not match the sha256 it was pinned to'); + ).rejects.toThrow('Field "document": uploaded file does not match the sha256 it was pinned to'); }); it('accepts content matching the pinned sha256', async () => { @@ -175,7 +175,7 @@ describe('resolveUploadedFileValues', () => { const storage = makeStorage({ download: jest.fn().mockResolvedValue(content) }); const resolved = await resolveUploadedFileValues( - { document: makeHandle({ sha256: pinned }) }, + { document: makeHandle({ sha256Base64: pinned }) }, authInfo, makeUploads(storage), ); @@ -214,4 +214,82 @@ describe('resolveUploadedFileValues', () => { expect(storage.download).toHaveBeenCalledTimes(6); expect(peak).toBeLessThanOrEqual(2); }); + + it('names the field in the error, so the model knows which file to re-upload', async () => { + const storage = makeStorage({ download: jest.fn().mockResolvedValue(Buffer.alloc(0)) }); + + await expect( + resolveUploadedFileValues({ invoice: makeHandle() }, authInfo, makeUploads(storage)), + ).rejects.toThrow('Field "invoice"'); + }); + + it('enforces the limit on the downloaded bytes when getSize under-reports', async () => { + const storage = makeStorage({ + getSize: jest.fn().mockResolvedValue(5), + download: jest.fn().mockResolvedValue(Buffer.alloc(11)), + }); + + await expect( + resolveUploadedFileValues( + { document: makeHandle() }, + authInfo, + makeUploads(storage, { maxBytes: 10 }), + ), + ).rejects.toThrow('above the 10 byte limit'); + }); + + it.each([ + ['null', null], + ['NaN', NaN], + ])('still enforces the limit when getSize answers %s', async (_, size) => { + const storage = makeStorage({ + getSize: jest.fn().mockResolvedValue(size), + download: jest.fn().mockResolvedValue(Buffer.alloc(11)), + }); + + await expect( + resolveUploadedFileValues( + { document: makeHandle() }, + authInfo, + makeUploads(storage, { maxBytes: 10 }), + ), + ).rejects.toThrow('above the 10 byte limit'); + }); + + it('accepts a file of exactly maxBytes', async () => { + const storage = makeStorage({ download: jest.fn().mockResolvedValue(Buffer.alloc(10)) }); + + const resolved = await resolveUploadedFileValues( + { document: makeHandle() }, + authInfo, + makeUploads(storage, { maxBytes: 10 }), + ); + + expect((resolved.document as { buffer: Buffer }).buffer).toHaveLength(10); + }); + + it('rejects a forged handle without spending a download slot', async () => { + const storage = makeStorage(); + + await expect( + resolveUploadedFileValues( + { document: makeHandle({ userId: 999 }) }, + authInfo, + makeUploads(storage), + ), + ).rejects.toThrow('Handle was issued to another user'); + + expect(storage.getSize).not.toHaveBeenCalled(); + expect(storage.download).not.toHaveBeenCalled(); + }); + + it('surfaces a download rejection rather than swallowing it', async () => { + const storage = makeStorage({ + download: jest.fn().mockRejectedValue(new Error('NoSuchKey')), + }); + + await expect( + resolveUploadedFileValues({ document: makeHandle() }, authInfo, makeUploads(storage)), + ).rejects.toThrow('NoSuchKey'); + }); }); diff --git a/packages/mcp-server/test/file-uploads/routes.test.ts b/packages/mcp-server/test/file-uploads/routes.test.ts index 0dbf36fcbe..07048e5c92 100644 --- a/packages/mcp-server/test/file-uploads/routes.test.ts +++ b/packages/mcp-server/test/file-uploads/routes.test.ts @@ -141,21 +141,52 @@ describe('POST /files', () => { expect(storage.createUploadUrl).toHaveBeenCalledWith( expect.objectContaining({ sha256: expectedBase64 }), ); - expect(claimsOf(response.body.fileHandle).sha256).toBe(expectedBase64); + expect(claimsOf(response.body.fileHandle).sha256Base64).toBe(expectedBase64); }); - it('sanitizes the filename so it cannot escape the storage key', async () => { + it.each([ + ['../etc/passwd.pdf', '.._etc_passwd.pdf'], + ['..', 'file'], + ['.', 'file'], + ['....', 'file'], + ['a/b/c.pdf', 'a_b_c.pdf'], + ])('sanitizes %p to %p so it cannot escape the storage key', async (filename, expected) => { + const { app, storage } = makeApp({ auth: authenticatedUser }); + + const response = await request(app).post('/files').send({ filename, mimeType: 'text/plain' }); + + expect(response.status).toBe(200); + expect((storage.createUploadUrl as jest.Mock).mock.calls[0][0].key).toBe( + `mcp-uploads/${claimsOf(response.body.fileHandle).key.split('/')[1]}/${expected}`, + ); + }); + + it('truncates a long filename from the front so the extension survives', async () => { const { app, storage } = makeApp({ auth: authenticatedUser }); const response = await request(app) .post('/files') - .send({ filename: '../etc/passwd.pdf', mimeType: 'application/pdf' }); + .send({ filename: `${'a'.repeat(200)}.pdf`, mimeType: 'application/pdf' }); expect(response.status).toBe(200); - const { key } = (storage.createUploadUrl as jest.Mock).mock.calls[0][0]; - expect(key.split('/').pop()).not.toMatch(/[/]/); - expect(key).toMatch(/^mcp-uploads\/[0-9a-f-]{36}\/[\w.\- ()]+$/); + const name = (storage.createUploadUrl as jest.Mock).mock.calls[0][0].key.split('/').pop(); + expect(name).toHaveLength(128); + expect(name.endsWith('.pdf')).toBe(true); + }); + + it('accepts a digest already given in base64', async () => { + const { app, storage } = makeApp({ auth: authenticatedUser }); + const base64 = Buffer.alloc(32, 1).toString('base64'); + + const response = await request(app) + .post('/files') + .send({ filename: 'report.pdf', mimeType: 'application/pdf', sha256: base64 }); + + expect(response.status).toBe(200); + expect(storage.createUploadUrl).toHaveBeenCalledWith( + expect.objectContaining({ sha256: base64 }), + ); }); it('keeps a filename the agent can round-trip, spaces and all', async () => { diff --git a/packages/mcp-server/test/file-uploads/semaphore.test.ts b/packages/mcp-server/test/file-uploads/semaphore.test.ts new file mode 100644 index 0000000000..d3afe99097 --- /dev/null +++ b/packages/mcp-server/test/file-uploads/semaphore.test.ts @@ -0,0 +1,91 @@ +import createSemaphore from '../../src/file-uploads/semaphore'; + +const deferred = () => { + let resolve: (value?: unknown) => void; + let reject: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +}; + +describe('createSemaphore', () => { + it('runs a task and returns its value', async () => { + await expect(createSemaphore(1)(async () => 'done')).resolves.toBe('done'); + }); + + it('holds a task until a slot frees up', async () => { + const run = createSemaphore(1); + const first = deferred(); + let secondStarted = false; + + const firstCall = run(() => first.promise); + const secondCall = run(async () => { + secondStarted = true; + }); + + await Promise.resolve(); + expect(secondStarted).toBe(false); + + first.resolve(); + await firstCall; + await secondCall; + expect(secondStarted).toBe(true); + }); + + // Without the finally, a run of failures would exhaust the slots and hang every later task. + it('frees the slot when a task rejects', async () => { + const run = createSemaphore(2); + + await Promise.all( + Array.from({ length: 5 }, () => + expect( + run(async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'), + ), + ); + + await expect(run(async () => 'still works')).resolves.toBe('still works'); + }); + + it('surfaces the task rejection unchanged', async () => { + await expect( + createSemaphore(1)(async () => { + throw new Error('storage is down'); + }), + ).rejects.toThrow('storage is down'); + }); + + it.each([[0], [-1]])('treats a limit of %p as 1 rather than hanging forever', async limit => { + const run = createSemaphore(limit); + + await expect(run(async () => 'ran')).resolves.toBe('ran'); + await expect(run(async () => 'ran again')).resolves.toBe('ran again'); + }); + + it('never exceeds the limit under load', async () => { + let active = 0; + let peak = 0; + const run = createSemaphore(3); + + await Promise.all( + Array.from({ length: 20 }, () => + run(async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise(resolve => { + setTimeout(resolve, 1); + }); + active -= 1; + }), + ), + ); + + expect(peak).toBe(3); + expect(active).toBe(0); + }); +}); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 1bd0c74311..abd9318ae8 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3603,3 +3603,36 @@ describe('Logo URL', () => { expect(response.headers.get('content-type')).toContain('image/png'); }); }); + +describe('file uploads route', () => { + const storage = { + createUploadUrl: jest.fn().mockResolvedValue({ url: 'https://storage.example/put' }), + download: jest.fn(), + getSize: jest.fn(), + }; + + const buildApp = async (fileUploads?: { storage: typeof storage }) => + new ForestMCPServer({ + envSecret: 'test-env-secret', + authSecret: 'test-auth-secret', + forestServerUrl: 'https://test.forestadmin.com', + ...(fileUploads && { fileUploads }), + }).buildExpressApp(new URL('https://agent.example')); + + it('does not mount /files when the option is absent', async () => { + const response = await request(await buildApp()) + .post('/files') + .send({}); + + expect(response.status).toBe(404); + }); + + it('mounts /files behind bearer auth when the option is set', async () => { + const response = await request(await buildApp({ storage })) + .post('/files') + .send({}); + + expect(response.status).toBe(401); + expect(storage.createUploadUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index a6557a28b4..dd89aea8a9 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -277,6 +277,26 @@ describe('declareGetActionFormTool', () => { expect(mockTryToSetFields).toHaveBeenCalledWith(values); }); + it('passes an upload handle through verbatim instead of resolving it', async () => { + const mockTryToSetFields = jest.fn().mockResolvedValue([]); + const mockAction = jest.fn().mockResolvedValue({ + getFields: jest.fn().mockReturnValue([]), + tryToSetFields: mockTryToSetFields, + }); + mockBuildClientWithActions.mockResolvedValue({ + rpcClient: { collection: jest.fn().mockReturnValue({ action: mockAction }) }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const values = { document: '$uploadedFile:some-token' }; + await registeredToolHandler( + { collectionName: 'users', actionName: 'sendEmail', recordIds: [1], values }, + mockExtra, + ); + + expect(mockTryToSetFields).toHaveBeenCalledWith({ document: '$uploadedFile:some-token' }); + }); + it('should not call tryToSetFields when values are not provided', async () => { const mockGetFields = jest.fn().mockReturnValue([]); const mockTryToSetFields = jest.fn().mockResolvedValue([]); From 8b6a0c69f510192ed8d4c60620208951ab70d63c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 10 Aug 2026 15:11:34 +0200 Subject: [PATCH 06/39] fix: address the automated review findings - the embedded mount never dispatched /files: makeIsMcpRoute was built without the fileUploads flag, so the option was plumbed but the route still 404ed, leaving the feature unusable on agent.mountAiMcpServer - isFile accepted { buffer, mimeType } with no name, encoding a data uri without name= and producing a File whose required name was missing - warn when handleTtlSeconds equals uploadUrlTtlSeconds, which leaves zero margin to redeem, not only when it is shorter getType() now answers 'StringList' where it used to leak the wire array ['String'], so the agent-testing expectation follows. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent-client/src/action-fields/file-value.ts | 3 ++- packages/agent-testing/test/action.test.ts | 2 +- packages/agent/src/agent.ts | 4 +++- packages/mcp-server/src/file-uploads/types.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/agent-client/src/action-fields/file-value.ts b/packages/agent-client/src/action-fields/file-value.ts index 9f58c54b9d..e4f09b343c 100644 --- a/packages/agent-client/src/action-fields/file-value.ts +++ b/packages/agent-client/src/action-fields/file-value.ts @@ -17,7 +17,8 @@ function isFile(value: unknown): value is File { typeof value === 'object' && value !== null && Buffer.isBuffer(candidate.buffer) && - typeof candidate.mimeType === 'string' + typeof candidate.mimeType === 'string' && + typeof candidate.name === 'string' ); } diff --git a/packages/agent-testing/test/action.test.ts b/packages/agent-testing/test/action.test.ts index a8cdbec741..6ab9c277cd 100644 --- a/packages/agent-testing/test/action.test.ts +++ b/packages/agent-testing/test/action.test.ts @@ -214,7 +214,7 @@ describe('action', () => { 'Json', 'Number', 'String', - ['String'], + 'StringList', 'Number', 'Enum', 'Number', diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index e3461977f4..2311a27027 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -407,7 +407,9 @@ export default class Agent extends FrameworkMounter }); const httpCallback = await mcpServer.getHttpCallback(); - const isMcpRoute = makeIsMcpRoute(this.mcpBasePath); + const isMcpRoute = makeIsMcpRoute(this.mcpBasePath, { + fileUploads: Boolean(this.mcpFileUploads), + }); mcpLogger('Info', 'Server initialized successfully'); mcpLogger( diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index 36a171e5f3..6271676757 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -83,7 +83,7 @@ export function resolveFileUploads( // A handle expiring before its upload URL means uploads succeed and every redemption then // fails with a bare "jwt expired". - if (handleTtlSeconds < uploadUrlTtlSeconds) { + if (handleTtlSeconds <= uploadUrlTtlSeconds) { logger?.( 'Warn', `fileUploads.handleTtlSeconds=${handleTtlSeconds} is shorter than ` + From 2c574692f80517a911aea3b353bbd1aded5fdad5 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 14:31:24 +0200 Subject: [PATCH 07/39] refactor(mcp-server): expose the upload destination as a tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /files was invisible to clients: the only thing telling a model it existed was a sentence in the executeAction description. A tool is listed by tools/list with its schema, so the client discovers it, and the call goes through the activity log like every other tool. The tool is registered only when a storage backend is configured, so a server without one never advertises a capability every call would reject. It also carries the prerequisite the server cannot check: the upload is an outbound HTTPS request made by the client. In a code execution sandbox the host of uploadUrl must be allowed for outbound traffic — on Claude Desktop under Additional allowed domains. Stated in the description and repeated in the response, so a model whose upload was blocked can tell that apart from an expired handle. Removes the express router, the /files route matching and its plumbing in the agent. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/src/agent.ts | 4 +- packages/mcp-server/CLAUDE.md | 2 +- packages/mcp-server/README.md | 24 +- .../mcp-server/src/file-uploads/routes.ts | 124 --------- packages/mcp-server/src/file-uploads/types.ts | 4 +- packages/mcp-server/src/mcp-paths.ts | 18 +- packages/mcp-server/src/server.ts | 29 ++- .../mcp-server/src/tools/execute-action.ts | 2 +- .../src/tools/request-file-upload.ts | 143 +++++++++++ .../test/file-uploads/routes.test.ts | 218 ---------------- packages/mcp-server/test/mcp-paths.test.ts | 22 -- packages/mcp-server/test/server.test.ts | 13 +- .../test/tools/execute-action.test.ts | 4 +- .../test/tools/request-file-upload.test.ts | 237 ++++++++++++++++++ 14 files changed, 431 insertions(+), 413 deletions(-) delete mode 100644 packages/mcp-server/src/file-uploads/routes.ts create mode 100644 packages/mcp-server/src/tools/request-file-upload.ts delete mode 100644 packages/mcp-server/test/file-uploads/routes.test.ts create mode 100644 packages/mcp-server/test/tools/request-file-upload.test.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 2311a27027..e3461977f4 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -407,9 +407,7 @@ export default class Agent extends FrameworkMounter }); const httpCallback = await mcpServer.getHttpCallback(); - const isMcpRoute = makeIsMcpRoute(this.mcpBasePath, { - fileUploads: Boolean(this.mcpFileUploads), - }); + const isMcpRoute = makeIsMcpRoute(this.mcpBasePath); mcpLogger('Info', 'Server initialized successfully'); mcpLogger( diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 41de211780..998a7a534d 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -16,7 +16,7 @@ Key flows that only make sense across files: - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). -- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. `POST /files` (bearer-protected, `mcp:action` scope, only mounted when enabled) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. When enabled, `makeIsMcpRoute(prefix, { fileUploads: true })` also claims `/files`. +- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. The `requestFileUpload` tool (`src/tools/request-file-upload.ts`, registered only when the option is set) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description, and so the call goes through the activity log like every other tool. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. ## Commands diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index d08571b0c3..a4e08feebb 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -168,7 +168,7 @@ The minimum for either value is 60 seconds; anything lower is raised to it. An i > **Experimental.** The MCP specification is still designing its own file transfer story > ([SEP-2631](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631)). The -> `UploadStorage` contract is expected to survive, but the `POST /files` route and the handle +> `UploadStorage` contract is expected to survive, but the `requestFileUpload` tool and the handle > format may change to follow the specification once it lands. Actions with **File fields** cannot normally run over MCP. The agent expects file values as data @@ -176,11 +176,26 @@ uris, which would transit the model's context window and exceed most MCP clients The `fileUploads` option enables them through an upload side-channel that keeps the bytes out of the conversation: -1. The client `POST`s `/files` (same Bearer token as `/mcp`) with `{ "filename", "mimeType", "sha256"? }` and receives a pre-authorized upload URL plus a signed `fileHandle` string. +1. The client calls the `requestFileUpload` tool with `{ "filename", "mimeType", "sha256"? }` and receives a pre-authorized upload URL plus a signed `fileHandle` string. 2. The client uploads the raw bytes directly to the storage backend, so they never pass through the MCP server or the model. 3. The client passes the handle (`"$uploadedFile:<...>"`) as the field value in `executeAction`. The server downloads the object and hands it to the agent. The model only ever exchanges the small handle. -It is available on the embedded mount too: `agent.mountAiMcpServer({ fileUploads: { storage } })`. +`requestFileUpload` is registered only when `fileUploads` is set, so a server without a storage backend never advertises it. It is available on the embedded mount too: `agent.mountAiMcpServer({ fileUploads: { storage } })`. + +### The client must be able to upload + +Step 2 is an ordinary HTTPS request, made by the client, outside the MCP protocol. The client has to +be able to make it: + +- **Claude Code** and custom agents: works, they have shell or HTTP access. +- **Claude Desktop and Claude.ai**: the attached file lands in the code execution sandbox and the + model can `curl -X PUT -T `, but **the sandbox blocks outbound traffic by + default**. The host of `uploadUrl` must be added under *Settings > Capabilities > Code execution + and file creation > Additional allowed domains*. Without it the upload fails and nothing on the + server side can tell you why — so document your bucket's host for your users. + +The tool states this prerequisite in its description and repeats it in its response, so a model +whose upload was blocked has the diagnosis in context. ```mermaid sequenceDiagram @@ -189,7 +204,7 @@ sequenceDiagram participant Storage as Storage backend participant Agent as Forest Admin agent - Client->>Server: POST /files {filename, mimeType, sha256?} + Client->>Server: requestFileUpload {filename, mimeType, sha256?} Server-->>Client: uploadUrl + fileHandle (user-bound JWT) Client->>Storage: PUT raw bytes to uploadUrl Note over Client,Storage: bytes bypass the server and the model @@ -278,7 +293,6 @@ Once running, the MCP server exposes the following endpoints: | Method | Path | Description | |--------|------|-------------| | POST | `/mcp` | Main MCP protocol endpoint (requires Bearer token) | -| POST | `/files` | Upload side-channel for action file fields (only with `fileUploads`; requires Bearer token) | | POST | `/oauth/authorize` | OAuth 2.0 authorization | | POST | `/oauth/token` | OAuth 2.0 token exchange | | GET | `/.well-known/oauth-protected-resource/mcp` | OAuth metadata discovery | diff --git a/packages/mcp-server/src/file-uploads/routes.ts b/packages/mcp-server/src/file-uploads/routes.ts deleted file mode 100644 index 32a85c86de..0000000000 --- a/packages/mcp-server/src/file-uploads/routes.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { ResolvedFileUploads } from './types'; -import type { Logger } from '../server'; -import type { Request, Response, Router } from 'express'; - -import * as crypto from 'crypto'; -import express from 'express'; - -import { UPLOADED_FILE_PREFIX } from './file-reference'; -import { signUploadHandle } from './handles'; - -const MIME_TYPE_PATTERN = /^[\w.+-]+\/[\w.+-]+$/; -const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; -const SHA256_BASE64_PATTERN = /^[A-Za-z0-9+/]{43}=$/; -const MAX_FILENAME_LENGTH = 128; - -// The storage key delimits segments with '/', so keep a conservative charset. Truncation keeps -// the tail so the extension survives. A name made only of dots would let a backend that joins -// paths climb out of the per-upload directory. -function sanitizeFilename(filename: string): string { - const safe = filename - .trim() - .slice(-MAX_FILENAME_LENGTH) - .replace(/[^\w.\- ()]/g, '_'); - - return /^\.+$/.test(safe) ? 'file' : safe; -} - -// Accepts the digest as hex (shasum -a 256 output) or base64. -function normalizeSha256(sha256: unknown): string | null | false { - if (sha256 === undefined || sha256 === null || sha256 === '') return null; - if (typeof sha256 !== 'string') return false; - if (SHA256_BASE64_PATTERN.test(sha256)) return sha256; - if (SHA256_HEX_PATTERN.test(sha256)) return Buffer.from(sha256, 'hex').toString('base64'); - - return false; -} - -/** - * Must be mounted behind requireBearerAuth so req.auth carries the caller's identity: the - * returned handle is bound to that user and can only be redeemed by them. - */ -export default function createFilesRouter(uploads: ResolvedFileUploads, logger: Logger): Router { - const router = express.Router(); - - router.post('/', async (req: Request, res: Response) => { - const userId = req.auth?.extra?.userId; - - if (userId === undefined || userId === null) { - res.status(401).json({ error: 'Missing or invalid access token.' }); - - return; - } - - const { filename, mimeType, sha256 } = (req.body ?? {}) as Record; - - if (typeof filename !== 'string' || !filename.trim()) { - res.status(400).json({ error: 'filename is required.' }); - - return; - } - - if (typeof mimeType !== 'string' || !MIME_TYPE_PATTERN.test(mimeType)) { - res.status(400).json({ error: 'mimeType is required, e.g. application/pdf.' }); - - return; - } - - const sha256Base64 = normalizeSha256(sha256); - - if (sha256Base64 === false) { - res.status(400).json({ error: 'sha256 must be the file digest as hex or base64.' }); - - return; - } - - const safeName = sanitizeFilename(filename); - const key = `${uploads.keyPrefix}${crypto.randomUUID()}/${safeName}`; - - const destination = await uploads.storage.createUploadUrl({ - key, - mimeType, - ...(sha256Base64 && { sha256: sha256Base64 }), - expiresInSeconds: uploads.uploadUrlTtlSeconds, - }); - - const handle = signUploadHandle( - { - key, - name: safeName, - mimeType, - userId: userId as number | string, - ...(sha256Base64 && { sha256Base64 }), - }, - uploads.authSecret, - uploads.handleTtlSeconds, - ); - - res.json({ - uploadUrl: destination.url, - method: destination.method ?? 'PUT', - headers: destination.headers ?? { 'Content-Type': mimeType }, - expiresInSeconds: uploads.uploadUrlTtlSeconds, - maxBytes: uploads.maxBytes, - fileHandle: `${UPLOADED_FILE_PREFIX}${handle}`, - usage: - 'Upload the raw file bytes to uploadUrl with the given method and headers, ' + - 'then pass fileHandle as the value of the action file field in executeAction. ' + - 'Provide sha256 in the request to pin the upload to that exact content.', - }); - }); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- error handlers need arity 4 - router.use((error: Error, req: Request, res: Response, next: express.NextFunction) => { - logger('Error', `/files error: ${error.message}`); - if (error.stack) logger('Error', `Stack: ${error.stack}`); - - // The chain also carries requireBearerAuth, which answers by itself. - if (res.headersSent) return; - - res.status(500).json({ error: 'Failed to create upload URL.' }); - }); - - return router; -} diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index 6271676757..4107fa1dba 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -27,8 +27,8 @@ export interface UploadStorage { /** * @experimental The MCP specification is still designing its own file transfer story - * (SEP-2631). The storage contract is expected to survive, but the `POST /files` route and - * the handle format may change to follow the specification once it lands. + * (SEP-2631). The storage contract is expected to survive, but the `requestFileUpload` tool + * and the handle format may change to follow the specification once it lands. */ export interface FileUploadsOptions { storage: UploadStorage; diff --git a/packages/mcp-server/src/mcp-paths.ts b/packages/mcp-server/src/mcp-paths.ts index f4227c40c1..d1eaf3aeb0 100644 --- a/packages/mcp-server/src/mcp-paths.ts +++ b/packages/mcp-server/src/mcp-paths.ts @@ -22,15 +22,11 @@ export function normalizeMountPath(input?: string): string { return collapsed; } -export interface McpRouteOptions { - fileUploads?: boolean; -} - /** * Well-known paths stay anchored at the origin root (per RFC 8414/9728) but carry the prefix * as a suffix, so a host's own root OAuth metadata is not claimed. */ -export function buildMcpPaths(prefix = '', options: McpRouteOptions = {}): string[] { +export function buildMcpPaths(prefix = ''): string[] { const normalized = normalizeMountPath(prefix); const wellKnown = normalized @@ -40,17 +36,11 @@ export function buildMcpPaths(prefix = '', options: McpRouteOptions = {}): strin ] : ['/.well-known/']; - return [ - ...wellKnown, - `${normalized}/oauth/`, - `${normalized}/mcp`, - // Claimed only when uploads are enabled, so a host app's own /files route keeps working. - ...(options.fileUploads ? [`${normalized}/files`] : []), - ]; + return [...wellKnown, `${normalized}/oauth/`, `${normalized}/mcp`]; } -export function makeIsMcpRoute(prefix = '', options: McpRouteOptions = {}): McpRouteMatcher { - const paths = buildMcpPaths(prefix, options); +export function makeIsMcpRoute(prefix = ''): McpRouteMatcher { + const paths = buildMcpPaths(prefix); // Match on the pathname (req.url carries the query string) and on a segment boundary, so // '/mcp?x=1' still matches and '/ai/mcp' does not shadow '/ai/mcp-dashboard'. diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 3e3c8e03f9..d2a57b3180 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -24,7 +24,6 @@ import cors from 'cors'; import express from 'express'; import * as http from 'http'; -import createFilesRouter from './file-uploads/routes'; import { resolveFileUploads } from './file-uploads/types'; import ForestOAuthProvider from './forest-oauth-provider'; import { createForestServerClient } from './http-client'; @@ -38,6 +37,7 @@ import declareExecuteActionTool from './tools/execute-action'; import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; +import declareRequestFileUploadTool from './tools/request-file-upload'; import declareUpdateTool from './tools/update'; import normalizeAgentUrl from './utils/normalize-agent-url'; import normalizeDomainList from './utils/normalize-domain-list'; @@ -93,6 +93,7 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { describeCollection: ['collectionName'], getActionForm: ['collectionName', 'actionName', 'recordIds'], executeAction: ['collectionName', 'actionName', 'recordIds'], + requestFileUpload: ['mimeType'], associate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordId'], dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], }; @@ -107,7 +108,8 @@ export type ToolName = | 'associate' | 'dissociate' | 'getActionForm' - | 'executeAction'; + | 'executeAction' + | 'requestFileUpload'; /** * Options for configuring the Forest Admin MCP Server @@ -261,6 +263,14 @@ export default class ForestMCPServer { { name: 'dissociate', register: () => declareDissociateTool(mcpServer, ctx) }, { name: 'getActionForm', register: () => declareGetActionFormTool(mcpServer, ctx) }, { name: 'executeAction', register: () => declareExecuteActionTool(mcpServer, ctx) }, + ...(this.fileUploads + ? [ + { + name: 'requestFileUpload' as const, + register: () => declareRequestFileUploadTool(mcpServer, ctx), + }, + ] + : []), ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); @@ -298,6 +308,7 @@ export default class ForestMCPServer { 'dissociate', 'getActionForm', 'executeAction', + 'requestFileUpload', ]; const enabled = new Set(options?.enabledTools ?? allToolNames); @@ -584,18 +595,6 @@ export default class ForestMCPServer { effectiveBaseUrl, ).href; - if (this.fileUploads) { - app.use( - `${prefix}/files`, - requireBearerAuth({ - verifier: oauthProvider, - requiredScopes: ['mcp:action'], - resourceMetadataUrl, - }), - createFilesRouter(this.fileUploads, this.logger), - ); - } - app.post( `${prefix}/mcp`, requireBearerAuth({ @@ -670,7 +669,7 @@ export default class ForestMCPServer { */ async getHttpCallback(baseUrl?: URL): Promise { const app = await this.buildExpressApp(baseUrl); - const isMcpRoute = makeIsMcpRoute(this.basePath, { fileUploads: Boolean(this.fileUploads) }); + const isMcpRoute = makeIsMcpRoute(this.basePath); return (req, res, next) => { const url = req.url || '/'; diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index f66c4aa326..c9d9991696 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -46,7 +46,7 @@ If you call executeAction with missing required fields, it will return an error ctx.fileUploads ? ` -To fill a file field, never inline base64 file content. Request an upload destination via POST /files (same Bearer token, JSON body with "filename", "mimeType" and optionally "sha256"), upload the raw bytes to the returned uploadUrl, and pass the returned fileHandle string as the field value.` +To fill a file field, never inline base64 file content. Call requestFileUpload to get an upload destination, upload the raw bytes there, and pass the returned fileHandle string as the field value.` : '' }`, inputSchema: argumentShape, diff --git a/packages/mcp-server/src/tools/request-file-upload.ts b/packages/mcp-server/src/tools/request-file-upload.ts new file mode 100644 index 0000000000..febb9b4844 --- /dev/null +++ b/packages/mcp-server/src/tools/request-file-upload.ts @@ -0,0 +1,143 @@ +import type { ToolContext } from '../tool-context'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import * as crypto from 'crypto'; +import { z } from 'zod'; + +import { UPLOADED_FILE_PREFIX } from '../file-uploads/file-reference'; +import { signUploadHandle } from '../file-uploads/handles'; +import registerToolWithLogging from '../utils/tool-with-logging'; + +const MIME_TYPE_PATTERN = /^[\w.+-]+\/[\w.+-]+$/; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; +const SHA256_BASE64_PATTERN = /^[A-Za-z0-9+/]{43}=$/; +const MAX_FILENAME_LENGTH = 128; + +const UPLOAD_PREREQUISITE = + 'Uploading requires an outbound HTTP request from your environment. In a code execution ' + + 'sandbox, the host of uploadUrl must be allowed for outbound traffic: on Claude Desktop, add ' + + 'it under Settings > Capabilities > Code execution and file creation > Additional allowed ' + + 'domains. A blocked request is a client configuration issue, not an expired handle.'; + +interface RequestFileUploadArgument { + filename: string; + mimeType: string; + sha256?: string; +} + +// The storage key delimits segments with '/', so keep a conservative charset. Truncation keeps +// the tail so the extension survives. A name made only of dots would let a backend that joins +// paths climb out of the per-upload directory. +function sanitizeFilename(filename: string): string { + const safe = filename + .trim() + .slice(-MAX_FILENAME_LENGTH) + .replace(/[^\w.\- ()]/g, '_'); + + return /^\.+$/.test(safe) ? 'file' : safe; +} + +// Accepts the digest as hex (shasum -a 256 output) or base64. +function normalizeSha256(sha256: string | undefined): string | undefined { + if (!sha256) return undefined; + if (SHA256_BASE64_PATTERN.test(sha256)) return sha256; + if (SHA256_HEX_PATTERN.test(sha256)) return Buffer.from(sha256, 'hex').toString('base64'); + + throw new Error('sha256 must be the file digest as hex or base64.'); +} + +export default function declareRequestFileUploadTool( + mcpServer: McpServer, + ctx: ToolContext, +): string { + const { logger, fileUploads } = ctx; + + return registerToolWithLogging( + mcpServer, + 'requestFileUpload', + { + title: 'Request a file upload destination', + description: `Get a destination to upload a file to, then reference it in an action form field. + +Call this whenever getActionForm shows a field of type "File" or "FileList". Never inline base64 file content as a field value: it would be far larger than any payload limit. + +Workflow: +1. Call this tool with the filename and mimeType. Pass sha256 to pin the upload to that exact content. +2. Upload the raw bytes to the returned uploadUrl, with the returned method and headers, for example "curl -X PUT -T ". The bytes must not pass through this tool or through your own output. +3. Pass the returned fileHandle string as the value of the file field in executeAction. + +${UPLOAD_PREREQUISITE} + +The handle expires, so run the upload and the action without a long pause in between.`, + inputSchema: { + filename: z.string().min(1).describe('Original file name, e.g. "invoice-2026-01.pdf".'), + mimeType: z.string().describe('Media type of the file, e.g. "application/pdf".'), + sha256: z + .string() + .optional() + .describe('Optional sha256 digest of the file, hex or base64, to pin the upload.'), + }, + }, + async (options: RequestFileUploadArgument, extra) => { + if (!fileUploads) { + throw new Error( + 'File uploads are not configured on this server. ' + + 'Ask the administrator to set the fileUploads option to enable action file fields.', + ); + } + + const userId = extra.authInfo?.extra?.userId as number | string | undefined; + + if (userId === undefined || userId === null) { + throw new Error('Cannot request a file upload without an authenticated user'); + } + + if (!MIME_TYPE_PATTERN.test(options.mimeType)) { + throw new Error('mimeType is required, e.g. application/pdf.'); + } + + const sha256Base64 = normalizeSha256(options.sha256); + const safeName = sanitizeFilename(options.filename); + const key = `${fileUploads.keyPrefix}${crypto.randomUUID()}/${safeName}`; + + const destination = await fileUploads.storage.createUploadUrl({ + key, + mimeType: options.mimeType, + ...(sha256Base64 && { sha256: sha256Base64 }), + expiresInSeconds: fileUploads.uploadUrlTtlSeconds, + }); + + const handle = signUploadHandle( + { + key, + name: safeName, + mimeType: options.mimeType, + userId, + ...(sha256Base64 && { sha256Base64 }), + }, + fileUploads.authSecret, + fileUploads.handleTtlSeconds, + ); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + uploadUrl: destination.url, + method: destination.method ?? 'PUT', + headers: destination.headers ?? { 'Content-Type': options.mimeType }, + expiresInSeconds: fileUploads.uploadUrlTtlSeconds, + maxBytes: fileUploads.maxBytes, + fileHandle: `${UPLOADED_FILE_PREFIX}${handle}`, + // Repeated in the response so the model has the diagnosis in context at the moment + // its upload fails, not only when it first read the tool description. + prerequisite: UPLOAD_PREREQUISITE, + }), + }, + ], + }; + }, + logger, + ); +} diff --git a/packages/mcp-server/test/file-uploads/routes.test.ts b/packages/mcp-server/test/file-uploads/routes.test.ts deleted file mode 100644 index 07048e5c92..0000000000 --- a/packages/mcp-server/test/file-uploads/routes.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { UploadStorage } from '../../src/file-uploads/types'; -import type { Logger } from '../../src/server'; -import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; -import type { Express } from 'express'; - -import express from 'express'; -import request from 'supertest'; - -import { verifyUploadHandle } from '../../src/file-uploads/handles'; -import createFilesRouter from '../../src/file-uploads/routes'; -import { resolveFileUploads } from '../../src/file-uploads/types'; - -const AUTH_SECRET = 'test-auth-secret'; -const mockLogger: Logger = jest.fn(); - -const authenticatedUser = { - token: 'token', - clientId: '42', - scopes: ['mcp:action'], - extra: { userId: 42 }, -} as unknown as AuthInfo; - -function makeApp(options: { storage?: UploadStorage; auth?: AuthInfo }): { - app: Express; - storage: UploadStorage; -} { - const storage: UploadStorage = options.storage ?? { - createUploadUrl: jest.fn().mockResolvedValue({ - url: 'https://storage.example/put?signed=1', - headers: { 'Content-Type': 'application/pdf' }, - }), - download: jest.fn(), - getSize: jest.fn(), - }; - - const uploads = resolveFileUploads({ storage }, AUTH_SECRET); - - const app = express(); - app.use(express.json()); - - // Stands in for requireBearerAuth, which attaches the verified AuthInfo to req.auth. - app.use((req, res, next) => { - req.auth = options.auth; - next(); - }); - - app.use('/files', createFilesRouter(uploads, mockLogger)); - - return { app, storage }; -} - -function claimsOf(fileHandle: string) { - return verifyUploadHandle(fileHandle.slice('$uploadedFile:'.length), 42, AUTH_SECRET); -} - -describe('POST /files', () => { - it('returns 401 when no authenticated user is attached to the request', async () => { - const { app } = makeApp({ auth: undefined }); - - const response = await request(app) - .post('/files') - .send({ filename: 'report.pdf', mimeType: 'application/pdf' }); - - expect(response.status).toBe(401); - expect(response.body).toEqual({ error: 'Missing or invalid access token.' }); - }); - - it('returns 400 when filename is missing or blank', async () => { - const { app } = makeApp({ auth: authenticatedUser }); - - const response = await request(app) - .post('/files') - .send({ filename: ' ', mimeType: 'application/pdf' }); - - expect(response.status).toBe(400); - expect(response.body).toEqual({ error: 'filename is required.' }); - }); - - it('returns 400 when mimeType is not a type/subtype pair', async () => { - const { app } = makeApp({ auth: authenticatedUser }); - - const response = await request(app) - .post('/files') - .send({ filename: 'report.pdf', mimeType: 'not a mime type' }); - - expect(response.status).toBe(400); - expect(response.body).toEqual({ error: 'mimeType is required, e.g. application/pdf.' }); - }); - - it('returns 400 when sha256 is neither hex nor base64', async () => { - const { app } = makeApp({ auth: authenticatedUser }); - - const response = await request(app) - .post('/files') - .send({ filename: 'report.pdf', mimeType: 'application/pdf', sha256: 'nope' }); - - expect(response.status).toBe(400); - expect(response.body).toEqual({ error: 'sha256 must be the file digest as hex or base64.' }); - }); - - it('returns an upload destination and a redeemable handle', async () => { - const { app, storage } = makeApp({ auth: authenticatedUser }); - - const response = await request(app) - .post('/files') - .send({ filename: 'report.pdf', mimeType: 'application/pdf' }); - - expect(response.status).toBe(200); - expect(response.body).toMatchObject({ - uploadUrl: 'https://storage.example/put?signed=1', - method: 'PUT', - headers: { 'Content-Type': 'application/pdf' }, - expiresInSeconds: 15 * 60, - maxBytes: 20 * 1024 * 1024, - }); - expect(response.body.fileHandle).toMatch(/^\$uploadedFile:/); - expect(response.body.usage).toContain('executeAction'); - - expect(storage.createUploadUrl).toHaveBeenCalledWith({ - key: expect.stringMatching(/^mcp-uploads\/[0-9a-f-]{36}\/report\.pdf$/), - mimeType: 'application/pdf', - expiresInSeconds: 15 * 60, - }); - - expect(claimsOf(response.body.fileHandle)).toMatchObject({ - name: 'report.pdf', - mimeType: 'application/pdf', - }); - }); - - it('normalizes a hex sha256 to base64 and pins both the destination and the handle', async () => { - const { app, storage } = makeApp({ auth: authenticatedUser }); - const hex = 'a'.repeat(64); - const expectedBase64 = Buffer.from(hex, 'hex').toString('base64'); - - const response = await request(app) - .post('/files') - .send({ filename: 'report.pdf', mimeType: 'application/pdf', sha256: hex }); - - expect(response.status).toBe(200); - expect(storage.createUploadUrl).toHaveBeenCalledWith( - expect.objectContaining({ sha256: expectedBase64 }), - ); - expect(claimsOf(response.body.fileHandle).sha256Base64).toBe(expectedBase64); - }); - - it.each([ - ['../etc/passwd.pdf', '.._etc_passwd.pdf'], - ['..', 'file'], - ['.', 'file'], - ['....', 'file'], - ['a/b/c.pdf', 'a_b_c.pdf'], - ])('sanitizes %p to %p so it cannot escape the storage key', async (filename, expected) => { - const { app, storage } = makeApp({ auth: authenticatedUser }); - - const response = await request(app).post('/files').send({ filename, mimeType: 'text/plain' }); - - expect(response.status).toBe(200); - expect((storage.createUploadUrl as jest.Mock).mock.calls[0][0].key).toBe( - `mcp-uploads/${claimsOf(response.body.fileHandle).key.split('/')[1]}/${expected}`, - ); - }); - - it('truncates a long filename from the front so the extension survives', async () => { - const { app, storage } = makeApp({ auth: authenticatedUser }); - - const response = await request(app) - .post('/files') - .send({ filename: `${'a'.repeat(200)}.pdf`, mimeType: 'application/pdf' }); - - expect(response.status).toBe(200); - - const name = (storage.createUploadUrl as jest.Mock).mock.calls[0][0].key.split('/').pop(); - expect(name).toHaveLength(128); - expect(name.endsWith('.pdf')).toBe(true); - }); - - it('accepts a digest already given in base64', async () => { - const { app, storage } = makeApp({ auth: authenticatedUser }); - const base64 = Buffer.alloc(32, 1).toString('base64'); - - const response = await request(app) - .post('/files') - .send({ filename: 'report.pdf', mimeType: 'application/pdf', sha256: base64 }); - - expect(response.status).toBe(200); - expect(storage.createUploadUrl).toHaveBeenCalledWith( - expect.objectContaining({ sha256: base64 }), - ); - }); - - it('keeps a filename the agent can round-trip, spaces and all', async () => { - const { app } = makeApp({ auth: authenticatedUser }); - - const response = await request(app) - .post('/files') - .send({ filename: 'rapport final (v2).pdf', mimeType: 'application/pdf' }); - - expect(claimsOf(response.body.fileHandle).name).toBe('rapport final (v2).pdf'); - }); - - it('returns 500 without leaking details when the storage backend fails', async () => { - const storage: UploadStorage = { - createUploadUrl: jest.fn().mockRejectedValue(new Error('bucket is on fire')), - download: jest.fn(), - getSize: jest.fn(), - }; - const { app } = makeApp({ auth: authenticatedUser, storage }); - - const response = await request(app) - .post('/files') - .send({ filename: 'report.pdf', mimeType: 'application/pdf' }); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Failed to create upload URL.' }); - expect(mockLogger).toHaveBeenCalledWith('Error', expect.stringContaining('bucket is on fire')); - }); -}); diff --git a/packages/mcp-server/test/mcp-paths.test.ts b/packages/mcp-server/test/mcp-paths.test.ts index dd422750b0..398c00bf21 100644 --- a/packages/mcp-server/test/mcp-paths.test.ts +++ b/packages/mcp-server/test/mcp-paths.test.ts @@ -111,26 +111,4 @@ describe('mcp-paths', () => { expect(matches(url)).toBe(false); }); }); - - describe('with file uploads enabled', () => { - const matches = makeIsMcpRoute('', { fileUploads: true }); - - it('claims /files only when the option is on', () => { - expect(buildMcpPaths('')).not.toContain('/files'); - expect(buildMcpPaths('', { fileUploads: true })).toContain('/files'); - expect(buildMcpPaths('/ai', { fileUploads: true })).toContain('/ai/files'); - }); - - it.each(['/files', '/files?x=1'])('claims %p', url => { - expect(matches(url)).toBe(true); - }); - - it('leaves the host /files route alone when the option is off', () => { - expect(makeIsMcpRoute('')('/files')).toBe(false); - }); - - it('does not shadow a sibling route like /files-admin', () => { - expect(matches('/files-admin')).toBe(false); - }); - }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index abd9318ae8..c64f2ba792 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3493,6 +3493,7 @@ describe('enabledTools', () => { 'dissociate', 'getActionForm', 'executeAction', + 'requestFileUpload', ], }); @@ -3604,7 +3605,7 @@ describe('Logo URL', () => { }); }); -describe('file uploads route', () => { +describe('file uploads tool', () => { const storage = { createUploadUrl: jest.fn().mockResolvedValue({ url: 'https://storage.example/put' }), download: jest.fn(), @@ -3619,20 +3620,20 @@ describe('file uploads route', () => { ...(fileUploads && { fileUploads }), }).buildExpressApp(new URL('https://agent.example')); - it('does not mount /files when the option is absent', async () => { - const response = await request(await buildApp()) + // The tool replaced POST /files, so nothing must answer there any more. + it.each([[undefined], [{ storage }]])('does not serve /files (fileUploads: %p)', async opts => { + const response = await request(await buildApp(opts)) .post('/files') .send({}); expect(response.status).toBe(404); }); - it('mounts /files behind bearer auth when the option is set', async () => { + it('still requires a bearer token on /mcp when uploads are enabled', async () => { const response = await request(await buildApp({ storage })) - .post('/files') + .post('/mcp') .send({}); expect(response.status).toBe(401); - expect(storage.createUploadUrl).not.toHaveBeenCalled(); }); }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 6d94ab8e5f..e99c0c9039 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -612,7 +612,7 @@ describe('declareExecuteActionTool', () => { fileUploads: fileUploads(), }); - expect(registeredToolConfig.description).toContain('POST /files'); + expect(registeredToolConfig.description).toContain('requestFileUpload'); expect(registeredToolConfig.description).toContain('never inline base64'); }); @@ -623,7 +623,7 @@ describe('declareExecuteActionTool', () => { collectionNames: [], }); - expect(registeredToolConfig.description).not.toContain('POST /files'); + expect(registeredToolConfig.description).not.toContain('requestFileUpload'); }); it('hands the uploaded file to agent-client, which owns the encoding', async () => { diff --git a/packages/mcp-server/test/tools/request-file-upload.test.ts b/packages/mcp-server/test/tools/request-file-upload.test.ts new file mode 100644 index 0000000000..372c2568e9 --- /dev/null +++ b/packages/mcp-server/test/tools/request-file-upload.test.ts @@ -0,0 +1,237 @@ +import type { UploadStorage } from '../../src/file-uploads/types'; +import type { ForestServerClient } from '../../src/http-client'; +import type { Logger } from '../../src/server'; +import type { RegisteredToolConfig } from '../helpers/registered-tool-config'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { verifyUploadHandle } from '../../src/file-uploads/handles'; +import { resolveFileUploads } from '../../src/file-uploads/types'; +import declareRequestFileUploadTool from '../../src/tools/request-file-upload'; + +const AUTH_SECRET = 'test-auth-secret'; +const mockLogger: Logger = jest.fn(); +const mockForestServerClient = { + forestServerUrl: 'https://api.forestadmin.com', + fetchSchema: jest.fn(), + createActivityLog: jest.fn(), + createMcpActivityLog: jest.fn(), + updateActivityLogStatus: jest.fn(), +} as unknown as ForestServerClient; + +const authenticatedExtra = { + authInfo: { token: 'test-token', extra: { userId: 42 } }, +} as unknown as RequestHandlerExtra; + +describe('declareRequestFileUploadTool', () => { + let mcpServer: McpServer; + let handler: (options: unknown, extra: unknown) => Promise<{ content: { text: string }[] }>; + let config: RegisteredToolConfig; + let storage: UploadStorage; + + const setup = (uploadsEnabled = true) => { + storage = { + createUploadUrl: jest.fn().mockResolvedValue({ + url: 'https://storage.example/put?signed=1', + headers: { 'Content-Type': 'application/pdf' }, + }), + download: jest.fn(), + getSize: jest.fn(), + }; + + declareRequestFileUploadTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + ...(uploadsEnabled && { fileUploads: resolveFileUploads({ storage }, AUTH_SECRET) }), + }); + }; + + const call = async (args: Record, extra = authenticatedExtra) => { + const result = await handler(args, extra); + + return JSON.parse(result.content[0].text); + }; + + // registerToolWithLogging reports execution errors as an isError result, not a rejection. + const callExpectingError = async (args: Record, extra = authenticatedExtra) => + (await handler(args, extra)) as unknown as { isError: boolean; content: { text: string }[] }; + + const claimsOf = (fileHandle: string) => + verifyUploadHandle(fileHandle.slice('$uploadedFile:'.length), 42, AUTH_SECRET); + + beforeEach(() => { + jest.clearAllMocks(); + mcpServer = { + registerTool: jest.fn((name, toolConfig, toolHandler) => { + config = toolConfig; + handler = toolHandler; + }), + } as unknown as McpServer; + }); + + describe('registration', () => { + it('tells the model never to inline base64', () => { + setup(); + + expect(config.description).toContain('Never inline base64 file content'); + }); + + it('documents the outbound request prerequisite, including the Desktop allowlist', () => { + setup(); + + expect(config.description).toContain('Additional allowed domains'); + }); + }); + + describe('handler', () => { + it('returns an upload destination and a handle redeemable by the caller', async () => { + setup(); + + const response = await call({ filename: 'report.pdf', mimeType: 'application/pdf' }); + + expect(response).toMatchObject({ + uploadUrl: 'https://storage.example/put?signed=1', + method: 'PUT', + headers: { 'Content-Type': 'application/pdf' }, + expiresInSeconds: 15 * 60, + maxBytes: 20 * 1024 * 1024, + }); + expect(response.fileHandle).toMatch(/^\$uploadedFile:/); + expect(claimsOf(response.fileHandle)).toMatchObject({ + name: 'report.pdf', + mimeType: 'application/pdf', + }); + }); + + it('repeats the prerequisite in the response, where a failing upload will read it', async () => { + setup(); + + const response = await call({ filename: 'report.pdf', mimeType: 'application/pdf' }); + + expect(response.prerequisite).toContain('Additional allowed domains'); + }); + + it('asks the storage for a key under the configured prefix', async () => { + setup(); + + await call({ filename: 'report.pdf', mimeType: 'application/pdf' }); + + expect(storage.createUploadUrl).toHaveBeenCalledWith({ + key: expect.stringMatching(/^mcp-uploads\/[0-9a-f-]{36}\/report\.pdf$/), + mimeType: 'application/pdf', + expiresInSeconds: 15 * 60, + }); + }); + + it('normalizes a hex digest to base64 and pins both destination and handle', async () => { + setup(); + const hex = 'a'.repeat(64); + const expected = Buffer.from(hex, 'hex').toString('base64'); + + const response = await call({ + filename: 'report.pdf', + mimeType: 'application/pdf', + sha256: hex, + }); + + expect(storage.createUploadUrl).toHaveBeenCalledWith( + expect.objectContaining({ sha256: expected }), + ); + expect(claimsOf(response.fileHandle).sha256Base64).toBe(expected); + }); + + it('accepts a digest already in base64', async () => { + setup(); + const base64 = Buffer.alloc(32, 1).toString('base64'); + + await call({ filename: 'report.pdf', mimeType: 'application/pdf', sha256: base64 }); + + expect(storage.createUploadUrl).toHaveBeenCalledWith( + expect.objectContaining({ sha256: base64 }), + ); + }); + + it.each([ + ['../etc/passwd.pdf', '.._etc_passwd.pdf'], + ['..', 'file'], + ['.', 'file'], + ['a/b/c.pdf', 'a_b_c.pdf'], + ])('sanitizes %p to %p so it cannot escape the storage key', async (filename, expected) => { + setup(); + + const response = await call({ filename, mimeType: 'text/plain' }); + + expect(claimsOf(response.fileHandle).name).toBe(expected); + }); + + it('keeps a filename the agent can round-trip, spaces and all', async () => { + setup(); + + const response = await call({ + filename: 'rapport final (v2).pdf', + mimeType: 'application/pdf', + }); + + expect(claimsOf(response.fileHandle).name).toBe('rapport final (v2).pdf'); + }); + + it('truncates a long filename from the front so the extension survives', async () => { + setup(); + + const response = await call({ + filename: `${'a'.repeat(200)}.pdf`, + mimeType: 'application/pdf', + }); + + const { name } = claimsOf(response.fileHandle); + expect(name).toHaveLength(128); + expect(name.endsWith('.pdf')).toBe(true); + }); + + it.each([ + ['a malformed digest', { sha256: 'nope' }, 'sha256 must be the file digest'], + ['a mime type that is not a pair', { mimeType: 'not a mime type' }, 'mimeType is required'], + ])('rejects %s', async (_, override, message) => { + setup(); + + const result = await callExpectingError({ + filename: 'report.pdf', + mimeType: 'application/pdf', + ...override, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain(message); + expect(storage.createUploadUrl).not.toHaveBeenCalled(); + }); + + it('rejects a call with no authenticated user', async () => { + setup(); + + const result = await callExpectingError( + { + filename: 'report.pdf', + mimeType: 'application/pdf', + }, + { authInfo: { token: 't', extra: {} } } as unknown as typeof authenticatedExtra, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('without an authenticated user'); + }); + + it('rejects when no storage backend is configured', async () => { + setup(false); + + const result = await callExpectingError({ + filename: 'report.pdf', + mimeType: 'application/pdf', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('File uploads are not configured on this server'); + }); + }); +}); From d1b2bbc84d9c71b4d5064c8f1144f55fb29a26bd Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 14:41:48 +0200 Subject: [PATCH 08/39] test(mcp-server): cover the file upload configuration and destination resolveFileUploads validates five options and warns on a handle that cannot outlive its upload window, none of which was tested. The upload destination had no coverage of the method/headers fallbacks either. Also drops a dead branch in resolve: download never used the parsed reference, so collectReferences now carries the handle instead of the value being parsed twice, and the unreachable kind guard is gone. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp-server/src/file-uploads/resolve.ts | 44 +++---- .../test/file-uploads/types.test.ts | 121 ++++++++++++++++++ .../test/tools/request-file-upload.test.ts | 50 ++++++++ 3 files changed, 190 insertions(+), 25 deletions(-) create mode 100644 packages/mcp-server/test/file-uploads/types.test.ts diff --git a/packages/mcp-server/src/file-uploads/resolve.ts b/packages/mcp-server/src/file-uploads/resolve.ts index 92f4cf944b..1b92ccfd32 100644 --- a/packages/mcp-server/src/file-uploads/resolve.ts +++ b/packages/mcp-server/src/file-uploads/resolve.ts @@ -1,4 +1,4 @@ -import type { FileReference } from './file-reference'; +import type { UploadHandleClaims } from './handles'; import type { ResolvedFileUploads } from './types'; import type { File } from '@forestadmin/agent-client'; import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; @@ -8,17 +8,21 @@ import * as crypto from 'crypto'; import parseFileReference from './file-reference'; import { verifyUploadHandle } from './handles'; -// Keyed by reference so one used by several fields is downloaded once; the value is the first -// field that mentioned it, which is what error messages name. -function collectReferences(values: Record): Map { - const references = new Map(); +// Keyed by reference so one used by several fields is downloaded once. The field is the first +// one that mentioned it, which is what error messages name. +function collectReferences( + values: Record, +): Map { + const references = new Map(); for (const [field, value] of Object.entries(values)) { const candidates = Array.isArray(value) ? value : [value]; candidates.forEach(candidate => { - if (parseFileReference(candidate) && !references.has(candidate as string)) { - references.set(candidate as string, field); + const parsed = parseFileReference(candidate); + + if (parsed && !references.has(candidate as string)) { + references.set(candidate as string, { field, handle: parsed.handle }); } }); } @@ -28,8 +32,7 @@ function collectReferences(values: Record): Map async function download( field: string, - reference: FileReference, - claims: ReturnType, + claims: UploadHandleClaims, uploads: ResolvedFileUploads, ): Promise { const tooLarge = (bytes: number) => @@ -98,27 +101,18 @@ export default async function resolveUploadedFileValues( // Verified before acquiring a download slot: it is pure CPU, and it gates everything // expensive, so a batch of forged handles is rejected instead of queueing behind the limit. - const verified = [...references].map(([reference, field]) => { - const parsed = parseFileReference(reference); - - if (parsed.kind !== 'uploadHandle') { - throw new Error(`Field "${field}": unsupported file reference`); - } - - return { - field, - reference, - parsed, - claims: verifyUploadHandle(parsed.handle, userId, uploads.authSecret), - }; - }); + const verified = [...references].map(([reference, { field, handle }]) => ({ + field, + reference, + claims: verifyUploadHandle(handle, userId, uploads.authSecret), + })); const files = new Map( await Promise.all( verified.map( - async ({ field, reference, parsed, claims }): Promise<[string, File]> => [ + async ({ field, reference, claims }): Promise<[string, File]> => [ reference, - await uploads.limitDownload(() => download(field, parsed, claims, uploads)), + await uploads.limitDownload(() => download(field, claims, uploads)), ], ), ), diff --git a/packages/mcp-server/test/file-uploads/types.test.ts b/packages/mcp-server/test/file-uploads/types.test.ts new file mode 100644 index 0000000000..6f0faadd85 --- /dev/null +++ b/packages/mcp-server/test/file-uploads/types.test.ts @@ -0,0 +1,121 @@ +import type { UploadStorage } from '../../src/file-uploads/types'; +import type { Logger } from '../../src/server'; + +import { resolveFileUploads } from '../../src/file-uploads/types'; + +const AUTH_SECRET = 'test-auth-secret'; + +const storage: UploadStorage = { + createUploadUrl: jest.fn(), + download: jest.fn(), + getSize: jest.fn(), +}; + +describe('resolveFileUploads', () => { + let logger: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + logger = jest.fn(); + }); + + it('returns undefined when the option is absent', () => { + expect(resolveFileUploads(undefined, AUTH_SECRET, logger)).toBeUndefined(); + }); + + it('applies the documented defaults', () => { + const resolved = resolveFileUploads({ storage }, AUTH_SECRET, logger); + + expect(resolved).toMatchObject({ + storage, + keyPrefix: 'mcp-uploads/', + uploadUrlTtlSeconds: 15 * 60, + handleTtlSeconds: 45 * 60, + maxBytes: 20 * 1024 * 1024, + authSecret: AUTH_SECRET, + }); + expect(logger).not.toHaveBeenCalled(); + }); + + it('keeps the values it is given', () => { + const resolved = resolveFileUploads( + { + storage, + keyPrefix: 'uploads/', + uploadUrlTtlSeconds: 60, + handleTtlSeconds: 120, + maxBytes: 1024, + maxConcurrentDownloads: 2, + }, + AUTH_SECRET, + logger, + ); + + expect(resolved).toMatchObject({ + keyPrefix: 'uploads/', + uploadUrlTtlSeconds: 60, + handleTtlSeconds: 120, + maxBytes: 1024, + }); + }); + + it('rejects a missing storage backend at startup', () => { + expect(() => resolveFileUploads({} as never, AUTH_SECRET, logger)).toThrow( + 'fileUploads.storage is required.', + ); + }); + + // A zero or negative limit used to queue every redemption with nothing left to release it, + // hanging forever instead of failing. + it.each([ + ['maxConcurrentDownloads', 0], + ['maxConcurrentDownloads', -1], + ['maxConcurrentDownloads', 1.5], + ['maxBytes', 0], + ['maxBytes', -10], + ['uploadUrlTtlSeconds', 0], + ['handleTtlSeconds', -60], + ['handleTtlSeconds', 60.5], + ])('rejects %s = %p at startup', (field, value) => { + expect(() => resolveFileUploads({ storage, [field]: value }, AUTH_SECRET, logger)).toThrow( + `Invalid fileUploads.${field} "${value}": it must be a positive integer.`, + ); + }); + + describe('when the handle cannot outlive the upload window', () => { + it.each([ + ['shorter', 60], + ['equal', 900], + ])('warns when the handle ttl is %s', (_, handleTtlSeconds) => { + resolveFileUploads( + { storage, uploadUrlTtlSeconds: 900, handleTtlSeconds }, + AUTH_SECRET, + logger, + ); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining(`fileUploads.handleTtlSeconds=${handleTtlSeconds}`), + ); + }); + + it('does not warn when the handle outlives it', () => { + resolveFileUploads( + { storage, uploadUrlTtlSeconds: 900, handleTtlSeconds: 901 }, + AUTH_SECRET, + logger, + ); + + expect(logger).not.toHaveBeenCalled(); + }); + + it('tolerates being called without a logger', () => { + expect(() => + resolveFileUploads( + { storage, uploadUrlTtlSeconds: 900, handleTtlSeconds: 60 }, + AUTH_SECRET, + ), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/mcp-server/test/tools/request-file-upload.test.ts b/packages/mcp-server/test/tools/request-file-upload.test.ts index 372c2568e9..e5a94ea587 100644 --- a/packages/mcp-server/test/tools/request-file-upload.test.ts +++ b/packages/mcp-server/test/tools/request-file-upload.test.ts @@ -142,6 +142,56 @@ describe('declareRequestFileUploadTool', () => { expect(claimsOf(response.fileHandle).sha256Base64).toBe(expected); }); + it('falls back to PUT and a Content-Type header when the storage provides neither', async () => { + declareRequestFileUploadTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + fileUploads: resolveFileUploads( + { + storage: { + createUploadUrl: jest.fn().mockResolvedValue({ url: 'https://storage.example/put' }), + download: jest.fn(), + getSize: jest.fn(), + }, + }, + AUTH_SECRET, + ), + }); + + const response = await call({ filename: 'report.pdf', mimeType: 'application/pdf' }); + + expect(response.method).toBe('PUT'); + expect(response.headers).toEqual({ 'Content-Type': 'application/pdf' }); + }); + + it('uses the method and headers the storage returns', async () => { + declareRequestFileUploadTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + fileUploads: resolveFileUploads( + { + storage: { + createUploadUrl: jest.fn().mockResolvedValue({ + url: 'https://storage.example/post', + method: 'POST', + headers: { 'x-amz-checksum-sha256': 'abc' }, + }), + download: jest.fn(), + getSize: jest.fn(), + }, + }, + AUTH_SECRET, + ), + }); + + const response = await call({ filename: 'report.pdf', mimeType: 'application/pdf' }); + + expect(response.method).toBe('POST'); + expect(response.headers).toEqual({ 'x-amz-checksum-sha256': 'abc' }); + }); + it('accepts a digest already in base64', async () => { setup(); const base64 = Buffer.alloc(32, 1).toString('base64'); From 97660d5654dbad9fbf2c957d37703fd72174e8d3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 15:03:38 +0200 Subject: [PATCH 09/39] fix: make a bad file value diagnosable by the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual end-to-end testing against a real agent surfaced two errors the caller could not act on. A non-data-uri on a File field threw a bare Error, which the agent's error middleware renders as a generic 500 — so a model that passed a filename instead of a handle learned nothing. It is now a ValidationError, which maps to a 400 carrying the message. And the friendly empty-file hint was unreachable: a backend honouring the contract rejects a missing object rather than returning zero bytes, so the most likely mistake — redeeming a handle that was never uploaded to — surfaced as a raw NoSuchKey. The download failure now names the field and keeps the cause. Co-Authored-By: Claude Opus 5 (1M context) --- packages/datasource-toolkit/src/utils/data-uri.ts | 9 +++++++-- .../datasource-toolkit/test/utils/data-uri.test.ts | 8 +++++++- packages/mcp-server/src/file-uploads/resolve.ts | 12 +++++++++++- .../mcp-server/test/file-uploads/resolve.test.ts | 9 +++++++-- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/datasource-toolkit/src/utils/data-uri.ts b/packages/datasource-toolkit/src/utils/data-uri.ts index ccab81acdf..dcba4dbcbf 100644 --- a/packages/datasource-toolkit/src/utils/data-uri.ts +++ b/packages/datasource-toolkit/src/utils/data-uri.ts @@ -1,5 +1,7 @@ import type { File } from '../interfaces/action'; +import { ValidationError } from '../errors'; + export function isDataUri(value: unknown): value is string { return typeof value === 'string' && value.startsWith('data:'); } @@ -24,9 +26,12 @@ export function parseDataUri(dataUri: string): File { if (!dataUri) return null; // Without this the split below yields undefined data and Buffer.from raises an opaque - // TypeError. Reachable from action values, which a model can populate freely. + // TypeError. Reachable from action values, which a model can populate freely. A bare Error + // would surface as a generic 500, so the caller would not learn what to send instead. if (!dataUri.startsWith('data:')) { - throw new Error(`Not a data uri: "${dataUri.slice(0, 32)}"`); + throw new ValidationError( + `Expected a file, got "${dataUri.slice(0, 32)}". A file value must be a data uri.`, + ); } const [header, data] = dataUri.substring(5).split(','); diff --git a/packages/datasource-toolkit/test/utils/data-uri.test.ts b/packages/datasource-toolkit/test/utils/data-uri.test.ts index 8b9eae8837..050254c81e 100644 --- a/packages/datasource-toolkit/test/utils/data-uri.test.ts +++ b/packages/datasource-toolkit/test/utils/data-uri.test.ts @@ -1,3 +1,4 @@ +import { ValidationError } from '../../src/errors'; import { isDataUri, makeDataUri, parseDataUri } from '../../src/utils/data-uri'; describe('DataUri', () => { @@ -88,7 +89,12 @@ describe('DataUri', () => { ['an upload sentinel', '$uploadedFile:eyJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ.sig'], ['an http url', 'https://example.com/f.pdf'], ])('rejects %s with a readable message instead of a TypeError', (_, value) => { - expect(() => parseDataUri(value)).toThrow('Not a data uri'); + expect(() => parseDataUri(value)).toThrow('A file value must be a data uri'); + }); + + // A bare Error surfaces as a generic 500 at the agent boundary, hiding the message. + it('throws a ValidationError so the message reaches the caller', () => { + expect(() => parseDataUri('report.pdf')).toThrow(ValidationError); }); }); diff --git a/packages/mcp-server/src/file-uploads/resolve.ts b/packages/mcp-server/src/file-uploads/resolve.ts index 1b92ccfd32..492d9b882b 100644 --- a/packages/mcp-server/src/file-uploads/resolve.ts +++ b/packages/mcp-server/src/file-uploads/resolve.ts @@ -48,8 +48,18 @@ async function download( throw tooLarge(size); } - const buffer = await uploads.storage.download(claims.key); + // A backend that honours the contract rejects a missing object, so this is the shape the most + // likely mistake takes: the handle was never uploaded to. The raw message alone reads as an + // infrastructure failure. The key it may contain is already in the model's context, inside the + // handle it just sent. + const buffer = await uploads.storage.download(claims.key).catch((error: Error) => { + throw new Error( + `Field "${field}": could not read the uploaded file. ` + + `Did the upload to uploadUrl succeed? (${error.message})`, + ); + }); + // Only a backend that creates empty objects reaches this. if (buffer.length === 0) { throw new Error( `Field "${field}": uploaded file is empty. Did the upload to uploadUrl succeed?`, diff --git a/packages/mcp-server/test/file-uploads/resolve.test.ts b/packages/mcp-server/test/file-uploads/resolve.test.ts index 9f72ddca66..a5e4ad12ef 100644 --- a/packages/mcp-server/test/file-uploads/resolve.test.ts +++ b/packages/mcp-server/test/file-uploads/resolve.test.ts @@ -283,13 +283,18 @@ describe('resolveUploadedFileValues', () => { expect(storage.download).not.toHaveBeenCalled(); }); - it('surfaces a download rejection rather than swallowing it', async () => { + // A contract-honouring backend rejects a missing object, so this is what "never uploaded" + // actually looks like — the empty-buffer branch only fires on backends that create the object. + it('turns a download rejection into an actionable message, keeping the cause', async () => { const storage = makeStorage({ download: jest.fn().mockRejectedValue(new Error('NoSuchKey')), }); await expect( resolveUploadedFileValues({ document: makeHandle() }, authInfo, makeUploads(storage)), - ).rejects.toThrow('NoSuchKey'); + ).rejects.toThrow( + 'Field "document": could not read the uploaded file. ' + + 'Did the upload to uploadUrl succeed? (NoSuchKey)', + ); }); }); From d7ca65782e9b6421e4f0cb83dbdc15b65d012a5b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 15:07:18 +0200 Subject: [PATCH 10/39] docs(_example): demonstrate action file uploads end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow had no runnable demonstration: exercising it required a cloud bucket and a smart action with a File field, so nobody could try it without building both. _example now carries a disk-backed UploadStorage that serves its own PUT endpoint, and the review collection gets an 'Attach a document' action with a File and a FileList field, reporting the name, mime type and byte count it received — which is what tells you the bytes crossed the chain intact. The storage authenticates nothing and is for localhost only; its objects are gitignored. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + packages/_example/src/forest/agent.ts | 6 +- .../src/forest/customizations/review.ts | 33 ++++++++- .../src/forest/local-upload-storage.ts | 72 +++++++++++++++++++ packages/mcp-server/README.md | 9 +++ 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 packages/_example/src/forest/local-upload-storage.ts diff --git a/.gitignore b/.gitignore index 02554be26a..c2cc03eb6c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ lerna-debug.log # forest-bff openapi --output default destination openapi.json +# local upload storage of the _example agent +.upload-storage + # yarn yarn-error.log .vscode/settings.json diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index 436e6627ec..afb2b0ce33 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -19,6 +19,7 @@ import customizeReview from './customizations/review'; import customizeSales from './customizations/sale'; import customizeStore from './customizations/store'; import createTypicode from './datasources/typicode'; +import createLocalUploadStorage from './local-upload-storage'; import mongoose, { connectionString } from '../connections/mongoose'; import sequelizeMsSql from '../connections/sequelize-mssql'; import sequelizeMySql from '../connections/sequelize-mysql'; @@ -93,7 +94,10 @@ export default function makeAgent() { return resultBuilder.value((rows?.[0]?.value as number) ?? 0); }) - .mountAiMcpServer(allowedOAuthClients ? { allowedOAuthClients } : undefined) + .mountAiMcpServer({ + ...(allowedOAuthClients && { allowedOAuthClients }), + fileUploads: { storage: createLocalUploadStorage() }, + }) .customizeCollection('card', customizeCard) .customizeCollection('account', customizeAccount) diff --git a/packages/_example/src/forest/customizations/review.ts b/packages/_example/src/forest/customizations/review.ts index 0cd5bdf7c1..f94a4fdd8b 100644 --- a/packages/_example/src/forest/customizations/review.ts +++ b/packages/_example/src/forest/customizations/review.ts @@ -1,4 +1,35 @@ import type { ReviewCustomizer } from '../typings'; export default (collection: ReviewCustomizer) => - collection.addManyToOneRelation('store', 'store', { foreignKey: 'storeId' }); + collection + .addManyToOneRelation('store', 'store', { foreignKey: 'storeId' }) + + .addAction('Attach a document', { + scope: 'Single', + form: [ + { label: 'Document', type: 'File', isRequired: true }, + { label: 'Extra pages', type: 'FileList' }, + { label: 'Note', type: 'String' }, + ], + execute: async (context, resultBuilder) => { + const document = context.formValues.Document as { + name: string; + mimeType: string; + buffer: Buffer; + }; + const extras = (context.formValues['Extra pages'] ?? []) as (typeof document)[]; + + const describe = (file: typeof document) => + `${file?.name} (${file?.mimeType}, ${file?.buffer?.length} bytes)`; + + return resultBuilder.success( + [ + `Received ${describe(document)}`, + extras.length + ? `plus ${extras.length}: ${extras.map(describe).join(', ')}` + : 'no extras', + `note: ${context.formValues.Note ?? '-'}`, + ].join(' — '), + ); + }, + }); diff --git a/packages/_example/src/forest/local-upload-storage.ts b/packages/_example/src/forest/local-upload-storage.ts new file mode 100644 index 0000000000..b0c03b0ce6 --- /dev/null +++ b/packages/_example/src/forest/local-upload-storage.ts @@ -0,0 +1,72 @@ +import type { UploadStorage } from '@forestadmin/mcp-server'; + +import * as fs from 'fs/promises'; +import * as http from 'http'; +import * as path from 'path'; + +/** + * Development-only UploadStorage: stores objects on disk and serves its own PUT endpoint, so the + * MCP file upload flow can be exercised without a cloud bucket. + * + * It does not authenticate the PUT, so the unguessable key is the only thing protecting an + * object. Fine on localhost, never in production — there, hand a real backend to `fileUploads` + * and let it sign the upload URL (see the mcp-server README). + */ +export default function createLocalUploadStorage( + port = Number(process.env.HTTP_PORT_UPLOAD_STORAGE ?? 3370), +): UploadStorage { + const root = path.join(process.cwd(), '.upload-storage'); + const pathOf = (key: string) => path.join(root, key.replace(/[^\w./-]/g, '_')); + + const log = (message: string) => { + // eslint-disable-next-line no-console + console.log(`[local-upload-storage] ${message}`); + }; + + http + .createServer((req, res) => { + const key = decodeURIComponent((req.url ?? '/').replace(/^\/+/, '')); + + if (req.method !== 'PUT' || !key) { + res.writeHead(405).end(); + + return; + } + + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk as Buffer)); + req.on('end', () => { + const body = Buffer.concat(chunks); + const destination = pathOf(key); + + fs.mkdir(path.dirname(destination), { recursive: true }) + .then(() => fs.writeFile(destination, body)) + .then(() => { + log(`stored ${key} (${body.length} bytes)`); + res.writeHead(200).end(); + }) + .catch((error: Error) => { + log(`failed to store ${key}: ${error.message}`); + res.writeHead(500).end(); + }); + }); + }) + .listen(port, () => { + log(`PUT endpoint on http://localhost:${port}, objects under ${root}`); + }); + + return { + async createUploadUrl({ key }) { + return { url: `http://localhost:${port}/${encodeURIComponent(key)}`, method: 'PUT' }; + }, + async download(key) { + return fs.readFile(pathOf(key)); + }, + async getSize(key) { + // undefined means "not cheaply knowable": a missing object is download's job to reject. + const stat = await fs.stat(pathOf(key)).catch(() => undefined); + + return stat?.size; + }, + }; +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index a4e08feebb..0957705d03 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -20,6 +20,7 @@ This MCP server provides HTTP REST API access to Forest Admin operations, enabli | `dissociate` | Dissociate records from a relation | | `getActionForm` | Get the form fields for a custom action | | `executeAction` | Execute a custom action | +| `requestFileUpload` | Get a destination to upload a file to, for an action `File` field (only with `fileUploads`) | ## Usage @@ -197,6 +198,14 @@ be able to make it: The tool states this prerequisite in its description and repeats it in its response, so a model whose upload was blocked has the diagnosis in context. +### Trying it locally + +`packages/_example` wires the whole flow with no cloud account: `local-upload-storage.ts` is a +disk-backed `UploadStorage` that serves its own PUT endpoint, and the `review` collection carries an +`Attach a document` action with a `File` and a `FileList` field. Start the example agent, connect an +MCP client to it, and ask for that action with a file — the action reports the name, mime type and +byte count it received. + ```mermaid sequenceDiagram participant Client as MCP client From f34cfca65b48571e4f700c8a03deaa4eaad4ab67 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 15:10:32 +0200 Subject: [PATCH 11/39] fix(_example): contain the local upload storage to its root The PUT key comes from the request URL, so it is caller-controlled, and sanitizing the charset was not enough: '.' and '/' are legal in a key, so '../../etc/passwd' resolved outside the storage root and the unauthenticated endpoint wrote there. Reproduced before the fix, rejected with a 400 after. pathOf now resolves then requires containment, and it runs inside the promise chain: throwing from the 'end' listener would have taken the process down instead of answering. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/forest/local-upload-storage.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/_example/src/forest/local-upload-storage.ts b/packages/_example/src/forest/local-upload-storage.ts index b0c03b0ce6..f364318576 100644 --- a/packages/_example/src/forest/local-upload-storage.ts +++ b/packages/_example/src/forest/local-upload-storage.ts @@ -16,7 +16,19 @@ export default function createLocalUploadStorage( port = Number(process.env.HTTP_PORT_UPLOAD_STORAGE ?? 3370), ): UploadStorage { const root = path.join(process.cwd(), '.upload-storage'); - const pathOf = (key: string) => path.join(root, key.replace(/[^\w./-]/g, '_')); + + // The key of a PUT comes from the request URL, so it is caller-controlled. Sanitizing the + // charset is not enough: '.' and '/' are legal in a key, and "../../etc/passwd" would resolve + // outside root. Resolve first, then require containment. + const pathOf = (key: string) => { + const resolved = path.resolve(root, key.replace(/[^\w./-]/g, '_')); + + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + throw new Error(`Key "${key}" resolves outside the storage root`); + } + + return resolved; + }; const log = (message: string) => { // eslint-disable-next-line no-console @@ -37,17 +49,22 @@ export default function createLocalUploadStorage( req.on('data', chunk => chunks.push(chunk as Buffer)); req.on('end', () => { const body = Buffer.concat(chunks); - const destination = pathOf(key); - fs.mkdir(path.dirname(destination), { recursive: true }) - .then(() => fs.writeFile(destination, body)) + // pathOf rejects a key that escapes the root, and a throw in this listener would take the + // process down, so it runs inside the chain the catch below covers. + Promise.resolve() + .then(async () => { + const destination = pathOf(key); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, body); + }) .then(() => { log(`stored ${key} (${body.length} bytes)`); res.writeHead(200).end(); }) .catch((error: Error) => { - log(`failed to store ${key}: ${error.message}`); - res.writeHead(500).end(); + log(`refused ${key}: ${error.message}`); + res.writeHead(400).end(); }); }); }) From e12cdc537aa27563f2229b94c7683c4b281413e9 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 15:21:11 +0200 Subject: [PATCH 12/39] fix(mcp-server): restore the scope check the route enforced, and name every object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving POST /files to a tool silently dropped an authorization check: the route was mounted behind mcp:action, while /mcp only requires mcp:read. A read-only token could therefore mint a pre-authorized write into the host's storage. The tool now checks the scope itself. A whitespace-only filename also sanitized down to an empty string, producing a key ending in '/' — a folder marker on S3-style backends — and a File whose name was empty. It is trimmed at the schema and falls back to 'file'. The mime type error said 'required' for a value that was supplied but malformed, sending the model down the wrong path. And CLAUDE.md claimed the tool goes through the activity log, which it does not. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/forest/local-upload-storage.ts | 79 ++++++++++--------- packages/mcp-server/CLAUDE.md | 2 +- .../src/tools/request-file-upload.ts | 20 ++++- packages/mcp-server/test/server.test.ts | 1 - .../test/tools/request-file-upload.test.ts | 31 +++++++- 5 files changed, 89 insertions(+), 44 deletions(-) diff --git a/packages/_example/src/forest/local-upload-storage.ts b/packages/_example/src/forest/local-upload-storage.ts index f364318576..7c363e9b77 100644 --- a/packages/_example/src/forest/local-upload-storage.ts +++ b/packages/_example/src/forest/local-upload-storage.ts @@ -35,43 +35,51 @@ export default function createLocalUploadStorage( console.log(`[local-upload-storage] ${message}`); }; - http - .createServer((req, res) => { - const key = decodeURIComponent((req.url ?? '/').replace(/^\/+/, '')); - - if (req.method !== 'PUT' || !key) { - res.writeHead(405).end(); - - return; - } - - const chunks: Buffer[] = []; - req.on('data', chunk => chunks.push(chunk as Buffer)); - req.on('end', () => { - const body = Buffer.concat(chunks); - - // pathOf rejects a key that escapes the root, and a throw in this listener would take the - // process down, so it runs inside the chain the catch below covers. - Promise.resolve() - .then(async () => { - const destination = pathOf(key); - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.writeFile(destination, body); - }) - .then(() => { - log(`stored ${key} (${body.length} bytes)`); - res.writeHead(200).end(); - }) - .catch((error: Error) => { - log(`refused ${key}: ${error.message}`); - res.writeHead(400).end(); - }); - }); - }) - .listen(port, () => { - log(`PUT endpoint on http://localhost:${port}, objects under ${root}`); + const server = http.createServer((req, res) => { + const rawKey = (req.url ?? '/').replace(/^\/+/, ''); + + if (req.method !== 'PUT' || !rawKey) { + res.writeHead(405).end(); + + return; + } + + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk as Buffer)); + req.on('end', () => { + const body = Buffer.concat(chunks); + + // Everything that can throw on a caller-controlled key runs inside this chain: both + // decodeURIComponent, which raises URIError on a lone '%', and pathOf, which rejects a key + // escaping the root. A throw in this listener would be an uncaughtException and take the + // whole agent down instead of failing one request. + Promise.resolve() + .then(async () => { + const destination = pathOf(decodeURIComponent(rawKey)); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, body); + }) + .then(() => { + log(`stored ${rawKey} (${body.length} bytes)`); + res.writeHead(200).end(); + }) + .catch((error: Error) => { + log(`refused ${rawKey}: ${error.message}`); + res.writeHead(400).end(); + }); }); + req.on('error', (error: Error) => log(`request failed: ${error.message}`)); + }); + + server.on('error', (error: Error) => { + log(`cannot listen on ${port}: ${error.message}. Set HTTP_PORT_UPLOAD_STORAGE.`); + }); + + server.listen(port, () => { + log(`PUT endpoint on http://localhost:${port}, objects under ${root}`); + }); + return { async createUploadUrl({ key }) { return { url: `http://localhost:${port}/${encodeURIComponent(key)}`, method: 'PUT' }; @@ -80,7 +88,6 @@ export default function createLocalUploadStorage( return fs.readFile(pathOf(key)); }, async getSize(key) { - // undefined means "not cheaply knowable": a missing object is download's job to reject. const stat = await fs.stat(pathOf(key)).catch(() => undefined); return stat?.size; diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 998a7a534d..b678f068c6 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -16,7 +16,7 @@ Key flows that only make sense across files: - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). -- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. The `requestFileUpload` tool (`src/tools/request-file-upload.ts`, registered only when the option is set) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description, and so the call goes through the activity log like every other tool. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. +- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. The `requestFileUpload` tool (`src/tools/request-file-upload.ts`, registered only when the option is set) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. ## Commands diff --git a/packages/mcp-server/src/tools/request-file-upload.ts b/packages/mcp-server/src/tools/request-file-upload.ts index febb9b4844..9b47201433 100644 --- a/packages/mcp-server/src/tools/request-file-upload.ts +++ b/packages/mcp-server/src/tools/request-file-upload.ts @@ -12,6 +12,7 @@ const MIME_TYPE_PATTERN = /^[\w.+-]+\/[\w.+-]+$/; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; const SHA256_BASE64_PATTERN = /^[A-Za-z0-9+/]{43}=$/; const MAX_FILENAME_LENGTH = 128; +const REQUIRED_SCOPE = 'mcp:action'; const UPLOAD_PREREQUISITE = 'Uploading requires an outbound HTTP request from your environment. In a code execution ' + @@ -34,10 +35,9 @@ function sanitizeFilename(filename: string): string { .slice(-MAX_FILENAME_LENGTH) .replace(/[^\w.\- ()]/g, '_'); - return /^\.+$/.test(safe) ? 'file' : safe; + return !safe || /^\.+$/.test(safe) ? 'file' : safe; } -// Accepts the digest as hex (shasum -a 256 output) or base64. function normalizeSha256(sha256: string | undefined): string | undefined { if (!sha256) return undefined; if (SHA256_BASE64_PATTERN.test(sha256)) return sha256; @@ -70,7 +70,11 @@ ${UPLOAD_PREREQUISITE} The handle expires, so run the upload and the action without a long pause in between.`, inputSchema: { - filename: z.string().min(1).describe('Original file name, e.g. "invoice-2026-01.pdf".'), + filename: z + .string() + .trim() + .min(1) + .describe('Original file name, e.g. "invoice-2026-01.pdf".'), mimeType: z.string().describe('Media type of the file, e.g. "application/pdf".'), sha256: z .string() @@ -92,8 +96,16 @@ The handle expires, so run the upload and the action without a long pause in bet throw new Error('Cannot request a file upload without an authenticated user'); } + // /mcp only requires mcp:read, but this mints a pre-authorized write into the host's + // storage. The route this tool replaced enforced mcp:action, so it is enforced here. + if (!extra.authInfo?.scopes?.includes(REQUIRED_SCOPE)) { + throw new Error(`Requesting a file upload requires the "${REQUIRED_SCOPE}" scope.`); + } + if (!MIME_TYPE_PATTERN.test(options.mimeType)) { - throw new Error('mimeType is required, e.g. application/pdf.'); + throw new Error( + `"${options.mimeType}" is not a media type. Expected a type/subtype pair, e.g. application/pdf.`, + ); } const sha256Base64 = normalizeSha256(options.sha256); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index c64f2ba792..8888c1b6ec 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3620,7 +3620,6 @@ describe('file uploads tool', () => { ...(fileUploads && { fileUploads }), }).buildExpressApp(new URL('https://agent.example')); - // The tool replaced POST /files, so nothing must answer there any more. it.each([[undefined], [{ storage }]])('does not serve /files (fileUploads: %p)', async opts => { const response = await request(await buildApp(opts)) .post('/files') diff --git a/packages/mcp-server/test/tools/request-file-upload.test.ts b/packages/mcp-server/test/tools/request-file-upload.test.ts index e5a94ea587..421729ac71 100644 --- a/packages/mcp-server/test/tools/request-file-upload.test.ts +++ b/packages/mcp-server/test/tools/request-file-upload.test.ts @@ -21,7 +21,7 @@ const mockForestServerClient = { } as unknown as ForestServerClient; const authenticatedExtra = { - authInfo: { token: 'test-token', extra: { userId: 42 } }, + authInfo: { token: 'test-token', scopes: ['mcp:read', 'mcp:action'], extra: { userId: 42 } }, } as unknown as RequestHandlerExtra; describe('declareRequestFileUploadTool', () => { @@ -242,7 +242,7 @@ describe('declareRequestFileUploadTool', () => { it.each([ ['a malformed digest', { sha256: 'nope' }, 'sha256 must be the file digest'], - ['a mime type that is not a pair', { mimeType: 'not a mime type' }, 'mimeType is required'], + ['a mime type that is not a pair', { mimeType: 'not a mime type' }, 'is not a media type'], ])('rejects %s', async (_, override, message) => { setup(); @@ -257,6 +257,33 @@ describe('declareRequestFileUploadTool', () => { expect(storage.createUploadUrl).not.toHaveBeenCalled(); }); + // /mcp only gates on mcp:read, so a read-only token would otherwise mint a pre-authorized + // write into the host's storage. + it('rejects a token that lacks the mcp:action scope', async () => { + setup(); + + const result = await callExpectingError({ filename: 'r.pdf', mimeType: 'application/pdf' }, { + authInfo: { token: 't', scopes: ['mcp:read'], extra: { userId: 42 } }, + } as unknown as typeof authenticatedExtra); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('requires the "mcp:action" scope'); + expect(storage.createUploadUrl).not.toHaveBeenCalled(); + }); + + // The zod schema trims and requires a non-empty name, but the fallback is what keeps a name + // that sanitizes down to nothing from becoming a key ending in '/' and an empty File name. + it.each([ + [' ', 'file'], + ['%%%', '___'], + ])('names an object %p as %p rather than leaving it empty', async (filename, expected) => { + setup(); + + const response = await call({ filename, mimeType: 'text/plain' }); + + expect(claimsOf(response.fileHandle).name).toBe(expected); + }); + it('rejects a call with no authenticated user', async () => { setup(); From 8c68ab44b2f40a116518e330c068781e0d65b74e Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 15:27:32 +0200 Subject: [PATCH 13/39] fix(_example): cap the body the local upload storage accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing authenticates the PUT endpoint and the body was buffered to the end with no limit, so an arbitrarily large request could run the example agent out of memory. It now refuses as soon as 25 MiB is crossed — above the 20 MiB fileUploads default, so an oversized upload is still reported by the server's own maxBytes check rather than masked here. Verified with a 30 MB body: 413, nothing written, the process survives and the heap stays flat. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/forest/local-upload-storage.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/_example/src/forest/local-upload-storage.ts b/packages/_example/src/forest/local-upload-storage.ts index 7c363e9b77..bca2fca2fa 100644 --- a/packages/_example/src/forest/local-upload-storage.ts +++ b/packages/_example/src/forest/local-upload-storage.ts @@ -12,6 +12,10 @@ import * as path from 'path'; * object. Fine on localhost, never in production — there, hand a real backend to `fileUploads` * and let it sign the upload URL (see the mcp-server README). */ +// Slightly above the 20 MiB fileUploads default, so an oversized upload is reported by the +// server's own maxBytes check rather than masked by this one. +const MAX_BODY_BYTES = 25 * 1024 * 1024; + export default function createLocalUploadStorage( port = Number(process.env.HTTP_PORT_UPLOAD_STORAGE ?? 3370), ): UploadStorage { @@ -45,8 +49,31 @@ export default function createLocalUploadStorage( } const chunks: Buffer[] = []; - req.on('data', chunk => chunks.push(chunk as Buffer)); + let received = 0; + let refused = false; + + // Nothing authenticates this endpoint, so an unbounded body is a way to run the agent out of + // memory. Refuse as soon as the limit is crossed instead of accumulating to the end. + req.on('data', chunk => { + if (refused) return; + + received += (chunk as Buffer).length; + + if (received > MAX_BODY_BYTES) { + refused = true; + log(`refused ${rawKey}: body exceeds ${MAX_BODY_BYTES} bytes`); + res.writeHead(413).end(); + req.destroy(); + + return; + } + + chunks.push(chunk as Buffer); + }); + req.on('end', () => { + if (refused) return; + const body = Buffer.concat(chunks); // Everything that can throw on a caller-controlled key runs inside this chain: both From bed0bbc7843d0d22ac0fe258a559d2825f6be55f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 15:31:14 +0200 Subject: [PATCH 14/39] fix(datasource-toolkit): reject a malformed data uri readably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prefix check was not enough. A data uri with no comma made Buffer.from raise a TypeError, and a lone '%' in a media type made decodeURIComponent raise a URIError — both surface as a generic 500, so a model that sent a malformed value never learned what to send instead. Both now produce the same ValidationError as a value that is not a data uri at all. A properly percent-encoded name still decodes. Co-Authored-By: Claude Opus 5 (1M context) --- .../datasource-toolkit/src/utils/data-uri.ts | 20 ++++++++++++------- .../test/utils/data-uri.test.ts | 13 ++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/datasource-toolkit/src/utils/data-uri.ts b/packages/datasource-toolkit/src/utils/data-uri.ts index dcba4dbcbf..93dcd99206 100644 --- a/packages/datasource-toolkit/src/utils/data-uri.ts +++ b/packages/datasource-toolkit/src/utils/data-uri.ts @@ -25,14 +25,16 @@ export function makeDataUri(file: File): string { export function parseDataUri(dataUri: string): File { if (!dataUri) return null; - // Without this the split below yields undefined data and Buffer.from raises an opaque - // TypeError. Reachable from action values, which a model can populate freely. A bare Error - // would surface as a generic 500, so the caller would not learn what to send instead. - if (!dataUri.startsWith('data:')) { - throw new ValidationError( + // Everything below is reachable from an action value, which a model populates freely, and every + // raw failure here is opaque: no comma makes Buffer.from raise a TypeError, and a lone '%' in a + // media type makes decodeURIComponent raise a URIError. Both surface as a generic 500, so the + // caller never learns what to send instead. + const malformed = () => + new ValidationError( `Expected a file, got "${dataUri.slice(0, 32)}". A file value must be a data uri.`, ); - } + + if (!dataUri.startsWith('data:') || !dataUri.includes(',')) throw malformed(); const [header, data] = dataUri.substring(5).split(','); const [mimeType, ...mediaTypes] = header.split(';'); @@ -42,7 +44,11 @@ export function parseDataUri(dataUri: string): File { const index = mediaType.indexOf('='); if (index !== -1) { - result[mediaType.substring(0, index)] = decodeURIComponent(mediaType.substring(index + 1)); + try { + result[mediaType.substring(0, index)] = decodeURIComponent(mediaType.substring(index + 1)); + } catch { + throw malformed(); + } } } diff --git a/packages/datasource-toolkit/test/utils/data-uri.test.ts b/packages/datasource-toolkit/test/utils/data-uri.test.ts index 050254c81e..6a1d7de812 100644 --- a/packages/datasource-toolkit/test/utils/data-uri.test.ts +++ b/packages/datasource-toolkit/test/utils/data-uri.test.ts @@ -96,6 +96,19 @@ describe('DataUri', () => { it('throws a ValidationError so the message reaches the caller', () => { expect(() => parseDataUri('report.pdf')).toThrow(ValidationError); }); + + // These used to raise a raw TypeError / URIError, which the agent renders as a 500. + it.each([ + ['no comma at all', 'data:text/plain;base64'], + ['a lone percent in a media type', 'data:text/plain;name=%;base64,aGk='], + ['an incomplete escape in the name', 'data:text/plain;name=100%.pdf;base64,aGk='], + ])('rejects a data uri with %s', (_, value) => { + expect(() => parseDataUri(value)).toThrow(ValidationError); + }); + + it('still accepts a percent-encoded name', () => { + expect(parseDataUri('data:text/plain;name=100%25.pdf;base64,aGk=').name).toBe('100%.pdf'); + }); }); describe('round trip', () => { From 49d688af7f2e5cf4186ed3436a7c9b1e3da51ce3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 15:45:56 +0200 Subject: [PATCH 15/39] fix(mcp-server): bound a storage read and the queue behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UploadStorage contract puts no bound on a read, so a backend that stopped answering held its concurrency slot indefinitely. The caller's own request timeout fires meanwhile — a real client cuts at 30s — so the failure was invisible here while the slots drained away one by one, and every later redemption queued behind them forever. Reads are now bounded by downloadTimeoutSeconds, 15s by default, which leaves half of a 30s caller budget for encoding and the action itself. The waiting queue is capped too: past that point the last waiter would be served around the time the caller has given up anyway, so failing tells the model to retry instead of holding it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/mcp-server/README.md | 8 +++- .../mcp-server/src/file-uploads/resolve.ts | 29 +++++++++++- .../mcp-server/src/file-uploads/semaphore.ts | 15 ++++++- packages/mcp-server/src/file-uploads/types.ts | 11 +++++ .../test/file-uploads/resolve.test.ts | 45 +++++++++++++++++++ .../test/file-uploads/semaphore.test.ts | 25 ++++++++++- 6 files changed, 128 insertions(+), 5 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 0957705d03..374bece1a0 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -279,7 +279,13 @@ const server = new ForestMCPServer({ The other options are `keyPrefix` (default `mcp-uploads/`), `uploadUrlTtlSeconds` (default 15 min), `handleTtlSeconds` (default 45 min, longer than the upload URL so a slow upload still leaves time to -run the action), `maxBytes` (default 20 MiB), and `maxConcurrentDownloads` (default 5). +run the action), `maxBytes` (default 20 MiB), `maxConcurrentDownloads` (default 5), and +`downloadTimeoutSeconds` (default 15 s). + +Lower `downloadTimeoutSeconds` if the clients calling your agent cut requests sooner than that. The +whole `executeAction` has to fit inside their timeout: reading the object, encoding it, and running +your action. A read that outlives the caller is wasted work — and left unbounded it would hold its +concurrency slot after the caller gave up. A few properties matter in production. diff --git a/packages/mcp-server/src/file-uploads/resolve.ts b/packages/mcp-server/src/file-uploads/resolve.ts index 492d9b882b..c02e013104 100644 --- a/packages/mcp-server/src/file-uploads/resolve.ts +++ b/packages/mcp-server/src/file-uploads/resolve.ts @@ -30,6 +30,23 @@ function collectReferences( return references; } +// The contract puts no bound on a storage read. Without this, a backend that stops answering +// holds its concurrency slot forever while the caller's own request timeout fires, so the +// failure would be invisible here and the slots would drain away one by one. +function withTimeout(operation: string, seconds: number, promise: Promise): Promise { + let timer: NodeJS.Timeout; + + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${operation} timed out after ${seconds}s`)), + seconds * 1000, + ); + }), + ]).finally(() => clearTimeout(timer)) as Promise; +} + async function download( field: string, claims: UploadHandleClaims, @@ -42,7 +59,11 @@ async function download( // A pre-authorized upload URL cannot always cap the object size, so the limit is enforced // here, before the bytes are read whenever the backend can report a size. - const size = await uploads.storage.getSize(claims.key); + const size = await withTimeout( + `Field "${field}": reading the size of the uploaded file`, + uploads.downloadTimeoutSeconds, + uploads.storage.getSize(claims.key), + ); if (typeof size === 'number' && Number.isFinite(size) && size > uploads.maxBytes) { throw tooLarge(size); @@ -52,7 +73,11 @@ async function download( // likely mistake takes: the handle was never uploaded to. The raw message alone reads as an // infrastructure failure. The key it may contain is already in the model's context, inside the // handle it just sent. - const buffer = await uploads.storage.download(claims.key).catch((error: Error) => { + const buffer = await withTimeout( + `Field "${field}": reading the uploaded file`, + uploads.downloadTimeoutSeconds, + uploads.storage.download(claims.key), + ).catch((error: Error) => { throw new Error( `Field "${field}": could not read the uploaded file. ` + `Did the upload to uploadUrl succeed? (${error.message})`, diff --git a/packages/mcp-server/src/file-uploads/semaphore.ts b/packages/mcp-server/src/file-uploads/semaphore.ts index 052f9240b3..8494edc9ff 100644 --- a/packages/mcp-server/src/file-uploads/semaphore.ts +++ b/packages/mcp-server/src/file-uploads/semaphore.ts @@ -1,16 +1,29 @@ export type RunExclusive = (task: () => Promise) => Promise; +// Queueing beyond this is wasted work: each read is already bounded by downloadTimeoutSeconds, so +// the last waiter of a full queue would be answered around the point the calling client has given +// up anyway. Failing fast tells the model to retry instead of holding it. +const QUEUE_FACTOR = 2; + export default function createSemaphore(rawLimit: number): RunExclusive { // A limit below 1 would queue every task with nothing left to release it, hanging forever. const limit = Math.max(1, rawLimit); + const maxQueued = limit * QUEUE_FACTOR; let active = 0; const queue: Array<() => void> = []; const acquire = () => - new Promise(resolve => { + new Promise((resolve, reject) => { if (active < limit) { active += 1; resolve(); + } else if (queue.length >= maxQueued) { + reject( + new Error( + `Too many uploads are being read at once (${limit} in progress, ${queue.length} ` + + 'waiting). Retry in a moment.', + ), + ); } else { queue.push(resolve); } diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index 4107fa1dba..f9b7a79563 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -38,6 +38,12 @@ export interface FileUploadsOptions { handleTtlSeconds?: number; maxBytes?: number; maxConcurrentDownloads?: number; + /** + * How long a single storage read may take. Keep it well under the request timeout of the + * clients calling the agent, so a slow backend fails here rather than being cut mid-flight. + * Defaults to 15 seconds. + */ + downloadTimeoutSeconds?: number; } export interface ResolvedFileUploads { @@ -46,6 +52,7 @@ export interface ResolvedFileUploads { uploadUrlTtlSeconds: number; handleTtlSeconds: number; maxBytes: number; + downloadTimeoutSeconds: number; authSecret: string; limitDownload: RunExclusive; } @@ -55,6 +62,7 @@ const DEFAULT_UPLOAD_URL_TTL_SECONDS = 15 * 60; const DEFAULT_HANDLE_TTL_SECONDS = 45 * 60; const DEFAULT_MAX_BYTES = 20 * 1024 * 1024; const DEFAULT_MAX_CONCURRENT_DOWNLOADS = 5; +const DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = 15; function positiveInteger(field: keyof FileUploadsOptions, value: number | undefined): number { if (value === undefined) return undefined; @@ -98,6 +106,9 @@ export function resolveFileUploads( uploadUrlTtlSeconds, handleTtlSeconds, maxBytes: positiveInteger('maxBytes', options.maxBytes) ?? DEFAULT_MAX_BYTES, + downloadTimeoutSeconds: + positiveInteger('downloadTimeoutSeconds', options.downloadTimeoutSeconds) ?? + DEFAULT_DOWNLOAD_TIMEOUT_SECONDS, authSecret, limitDownload: createSemaphore( positiveInteger('maxConcurrentDownloads', options.maxConcurrentDownloads) ?? diff --git a/packages/mcp-server/test/file-uploads/resolve.test.ts b/packages/mcp-server/test/file-uploads/resolve.test.ts index a5e4ad12ef..8718cca5b6 100644 --- a/packages/mcp-server/test/file-uploads/resolve.test.ts +++ b/packages/mcp-server/test/file-uploads/resolve.test.ts @@ -297,4 +297,49 @@ describe('resolveUploadedFileValues', () => { 'Did the upload to uploadUrl succeed? (NoSuchKey)', ); }); + + // Without a bound, a backend that stops answering holds its slot while the calling client's own + // timeout fires, so the failure would be invisible here. + it('gives up on a storage read that never answers', async () => { + const storage = makeStorage({ download: jest.fn().mockReturnValue(new Promise(() => {})) }); + const uploads = resolveFileUploads({ storage, downloadTimeoutSeconds: 1 }, AUTH_SECRET); + + await expect( + resolveUploadedFileValues({ document: makeHandle() }, authInfo, uploads), + ).rejects.toThrow('timed out after 1s'); + }); + + it('gives up on a size probe that never answers', async () => { + const storage = makeStorage({ getSize: jest.fn().mockReturnValue(new Promise(() => {})) }); + const uploads = resolveFileUploads({ storage, downloadTimeoutSeconds: 1 }, AUTH_SECRET); + + await expect( + resolveUploadedFileValues({ document: makeHandle() }, authInfo, uploads), + ).rejects.toThrow('timed out after 1s'); + expect(storage.download).not.toHaveBeenCalled(); + }); + + it('frees the slot after a timeout, so later redemptions still work', async () => { + let answer = false; + const storage = makeStorage({ + download: jest + .fn() + .mockImplementation(() => + answer ? Promise.resolve(Buffer.from('ok')) : new Promise(() => {}), + ), + }); + const uploads = resolveFileUploads( + { storage, maxConcurrentDownloads: 1, downloadTimeoutSeconds: 1 }, + AUTH_SECRET, + ); + + await expect( + resolveUploadedFileValues({ document: makeHandle() }, authInfo, uploads), + ).rejects.toThrow('timed out'); + + answer = true; + const resolved = await resolveUploadedFileValues({ document: makeHandle() }, authInfo, uploads); + + expect((resolved.document as { buffer: Buffer }).buffer.toString()).toBe('ok'); + }); }); diff --git a/packages/mcp-server/test/file-uploads/semaphore.test.ts b/packages/mcp-server/test/file-uploads/semaphore.test.ts index d3afe99097..81143ff214 100644 --- a/packages/mcp-server/test/file-uploads/semaphore.test.ts +++ b/packages/mcp-server/test/file-uploads/semaphore.test.ts @@ -72,8 +72,9 @@ describe('createSemaphore', () => { let peak = 0; const run = createSemaphore(3); + // 3 running plus a full queue of 6 — the most the semaphore accepts at once. await Promise.all( - Array.from({ length: 20 }, () => + Array.from({ length: 9 }, () => run(async () => { active += 1; peak = Math.max(peak, active); @@ -88,4 +89,26 @@ describe('createSemaphore', () => { expect(peak).toBe(3); expect(active).toBe(0); }); + + // Waiting longer than the caller's own timeout is wasted work, so the queue is bounded. + it('refuses work once the queue is full instead of growing it', async () => { + const run = createSemaphore(2); + const blocker = new Promise(resolve => { + setTimeout(resolve, 50); + }); + + const accepted = Array.from({ length: 6 }, () => run(() => blocker)); + const overflow = run(async () => 'never runs'); + + await expect(overflow).rejects.toThrow('Too many uploads are being read at once'); + await Promise.all(accepted); + }); + + it('accepts work again once the queue drains', async () => { + const run = createSemaphore(1); + + await Promise.all(Array.from({ length: 3 }, () => run(async () => 'done'))); + + await expect(run(async () => 'later')).resolves.toBe('later'); + }); }); From d46434fdddb7598f17c18ef1c6f0bfa136e9d9a9 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 16:09:51 +0200 Subject: [PATCH 16/39] feat(mcp-server): let the standalone server enable file uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A storage backend is an object with methods, so unlike every other standalone option it cannot travel through an environment variable. File uploads were therefore reachable only by embedding the server in an agent or by writing a custom entry point — which left the standalone CLI, the way this is actually deployed, unable to turn the feature on at all. FOREST_MCP_UPLOAD_STORAGE_MODULE points at a module default-exporting the options, or a function returning them. A missing path or a module without a storage fails at startup, like the other options, rather than running with uploads silently disabled. Co-Authored-By: Claude Opus 5 (1M context) --- packages/mcp-server/README.md | 26 +++++++ packages/mcp-server/src/cli.ts | 40 ++++++---- .../mcp-server/src/utils/load-file-uploads.ts | 49 +++++++++++++ .../test/utils/load-file-uploads.test.ts | 73 +++++++++++++++++++ 4 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 packages/mcp-server/src/utils/load-file-uploads.ts create mode 100644 packages/mcp-server/test/utils/load-file-uploads.test.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 374bece1a0..c9a6c17d8a 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -68,6 +68,7 @@ yarn start:dev # Development (loads .env file automatically) | `FOREST_AGENT_URL` | No | your environment's back-end URL | URL the MCP server uses to reach the back-end's data layer. Set it when the server runs next to a self-hosted back-end at an internal address (e.g. `http://localhost:3310`), instead of the public URL registered in Forest | | `FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS` | No | `3600` (1 hour) | Maximum lifetime of the OAuth access tokens the server issues (`tokenTtl.accessTokenSeconds`). Minimum `60` | | `FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS` | No | unbounded | Maximum time between two interactive logins (`tokenTtl.refreshTokenSeconds`). Unset, a client that keeps refreshing never signs in again. Minimum `60` | +| `FOREST_MCP_UPLOAD_STORAGE_MODULE` | No | - | Path to a module providing the `fileUploads` options, enabling action file uploads on the standalone server. See [Action File Uploads](#action-file-uploads) | #### Example Configuration @@ -183,6 +184,31 @@ the conversation: `requestFileUpload` is registered only when `fileUploads` is set, so a server without a storage backend never advertises it. It is available on the embedded mount too: `agent.mountAiMcpServer({ fileUploads: { storage } })`. +### On the standalone server + +A storage backend is an object with methods, so unlike every other standalone option it cannot +travel through an environment variable. Point `FOREST_MCP_UPLOAD_STORAGE_MODULE` at a module that +default-exports the options instead — a bad path or a module without a `storage` fails at startup +rather than running with uploads silently disabled: + +```javascript +// forest-upload-storage.js +module.exports = { + storage: { + /* createUploadUrl / download / getSize, as below */ + }, + maxBytes: 50 * 1024 * 1024, + downloadTimeoutSeconds: 10, +}; +``` + +```bash +FOREST_MCP_UPLOAD_STORAGE_MODULE=./forest-upload-storage.js npx forest-mcp-server +``` + +The module may also export a function, sync or async, returning the same options — useful when the +backend needs credentials fetched at boot. + ### The client must be able to upload Step 2 is an ordinary HTTPS request, made by the client, outside the MCP protocol. The client has to diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index cdfb430f62..cdb4373221 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -1,28 +1,36 @@ #!/usr/bin/env node import ForestMCPServer from './server'; +import loadFileUploads from './utils/load-file-uploads'; import parseDomainList from './utils/parse-domain-list'; import parseToolList from './utils/parse-tool-list'; const toSeconds = (value?: string) => (value === undefined ? undefined : Number(value)); -// Start the server when run directly as CLI -const server = new ForestMCPServer({ - forestServerUrl: process.env.FOREST_SERVER_URL || 'https://api.forestadmin.com', - forestAppUrl: process.env.FOREST_APP_URL || 'https://app.forestadmin.com', - envSecret: process.env.FOREST_ENV_SECRET, - authSecret: process.env.FOREST_AUTH_SECRET, - enabledTools: parseToolList(process.env.FOREST_MCP_ENABLED_TOOLS), - allowedOAuthClients: parseDomainList(process.env.FOREST_MCP_ALLOWED_OAUTH_CLIENTS), - agentUrl: process.env.FOREST_AGENT_URL, - // normalizeTokenTtl rejects NaN and non-positive values, so a bad variable fails at startup. - tokenTtl: { - accessTokenSeconds: toSeconds(process.env.FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS), - refreshTokenSeconds: toSeconds(process.env.FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS), - }, -}); +async function main() { + // Loaded before constructing, so a bad module fails at startup like every other option. + const fileUploads = await loadFileUploads(process.env.FOREST_MCP_UPLOAD_STORAGE_MODULE); + + const server = new ForestMCPServer({ + forestServerUrl: process.env.FOREST_SERVER_URL || 'https://api.forestadmin.com', + forestAppUrl: process.env.FOREST_APP_URL || 'https://app.forestadmin.com', + envSecret: process.env.FOREST_ENV_SECRET, + authSecret: process.env.FOREST_AUTH_SECRET, + enabledTools: parseToolList(process.env.FOREST_MCP_ENABLED_TOOLS), + allowedOAuthClients: parseDomainList(process.env.FOREST_MCP_ALLOWED_OAUTH_CLIENTS), + agentUrl: process.env.FOREST_AGENT_URL, + // normalizeTokenTtl rejects NaN and non-positive values, so a bad variable fails at startup. + tokenTtl: { + accessTokenSeconds: toSeconds(process.env.FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS), + refreshTokenSeconds: toSeconds(process.env.FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS), + }, + ...(fileUploads && { fileUploads }), + }); + + await server.run(); +} -server.run().catch(error => { +main().catch(error => { console.error('[FATAL] Server crashed:', error); process.exit(1); }); diff --git a/packages/mcp-server/src/utils/load-file-uploads.ts b/packages/mcp-server/src/utils/load-file-uploads.ts new file mode 100644 index 0000000000..2384b00805 --- /dev/null +++ b/packages/mcp-server/src/utils/load-file-uploads.ts @@ -0,0 +1,49 @@ +import type { FileUploadsOptions } from '../file-uploads/types'; + +import * as path from 'path'; + +/** + * Loads the `fileUploads` options of the standalone server from a module on disk. + * + * A storage backend is an object with methods, so it cannot travel through an environment + * variable like every other standalone option. Without this, file uploads would be reachable + * only by embedding the server in an agent or by writing a custom entry point. + * + * The module default-exports the options, or a function returning them: + * + * module.exports = { storage: myS3Storage, maxBytes: 50 * 1024 * 1024 }; + */ +export default async function loadFileUploads( + modulePath?: string, +): Promise { + if (!modulePath) return undefined; + + const resolved = path.resolve(process.cwd(), modulePath); + let loaded: { default?: unknown } & Record; + + try { + // require rather than import(): this package is CommonJS, and Node handles an ESM module here + // too, whereas a file:// specifier breaks under the test runner. + // eslint-disable-next-line global-require, import/no-dynamic-require, @typescript-eslint/no-var-requires + loaded = require(resolved); + } catch (error) { + throw new Error( + `Cannot load FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" (resolved to ${resolved}): ` + + `${(error as Error).message}`, + ); + } + + const exported = loaded.default ?? loaded; + const options = (typeof exported === 'function' ? await exported() : exported) as + | FileUploadsOptions + | undefined; + + if (!options?.storage) { + throw new Error( + `FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" must export { storage }, or a function ` + + 'returning it. See the fileUploads section of the mcp-server README.', + ); + } + + return options; +} diff --git a/packages/mcp-server/test/utils/load-file-uploads.test.ts b/packages/mcp-server/test/utils/load-file-uploads.test.ts new file mode 100644 index 0000000000..960797d034 --- /dev/null +++ b/packages/mcp-server/test/utils/load-file-uploads.test.ts @@ -0,0 +1,73 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import loadFileUploads from '../../src/utils/load-file-uploads'; + +describe('loadFileUploads', () => { + const written: string[] = []; + + const writeModule = (body: string) => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-uploads-')), 'storage.js'); + fs.writeFileSync(file, body); + written.push(file); + + return file; + }; + + const storageSource = `{ + createUploadUrl: async () => ({ url: 'https://storage.example/put' }), + download: async () => Buffer.from('x'), + getSize: async () => undefined, + }`; + + afterAll(() => written.forEach(file => fs.rmSync(path.dirname(file), { recursive: true }))); + + it('returns undefined when no module is configured', async () => { + await expect(loadFileUploads(undefined)).resolves.toBeUndefined(); + await expect(loadFileUploads('')).resolves.toBeUndefined(); + }); + + it('loads options exported directly', async () => { + const file = writeModule(`module.exports = { storage: ${storageSource}, maxBytes: 4242 };`); + + const options = await loadFileUploads(file); + + expect(options?.maxBytes).toBe(4242); + expect(typeof options?.storage.download).toBe('function'); + }); + + it('loads options returned by a function', async () => { + const file = writeModule(`module.exports = () => ({ storage: ${storageSource} });`); + + const options = await loadFileUploads(file); + + expect(typeof options?.storage.createUploadUrl).toBe('function'); + }); + + it('awaits a function returning a promise', async () => { + const file = writeModule(`module.exports = async () => ({ storage: ${storageSource} });`); + + await expect(loadFileUploads(file)).resolves.toMatchObject({ + storage: expect.any(Object), + }); + }); + + it('names the module and the resolved path when it cannot be loaded', async () => { + await expect(loadFileUploads('./does-not-exist.js')).rejects.toThrow( + /Cannot load FOREST_MCP_UPLOAD_STORAGE_MODULE "\.\/does-not-exist\.js" \(resolved to .+\)/, + ); + }); + + it('rejects a module that exports no storage, rather than starting without uploads', async () => { + const file = writeModule(`module.exports = { maxBytes: 10 };`); + + await expect(loadFileUploads(file)).rejects.toThrow('must export { storage }'); + }); + + it('rejects a function that returns no storage', async () => { + const file = writeModule(`module.exports = () => undefined;`); + + await expect(loadFileUploads(file)).rejects.toThrow('must export { storage }'); + }); +}); From 914548c0a8d0eecb870b6663ec580c45ff838563 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 16:23:16 +0200 Subject: [PATCH 17/39] fix: stop rejecting work the limits were never meant to reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all reachable by a legitimate caller. parseDataUri assigned any media-type key onto the parsed file, so a uri carrying 'buffer=oops' replaced the decoded bytes with a string and 'mimeType=...' contradicted the header. Only name and charset are read back now. The semaphore queue cap rejected any action carrying more file references than the limit allows — deterministically, retrying could not help, since one action submits them all at once. The queue is unbounded again; each read is bounded by downloadTimeoutSeconds, which is what keeps it draining. And a zero-byte buffer was treated as a failed upload, so a legitimately empty file could never be passed to an action. A missing object is already rejected by download, so zero bytes only ever means the user uploaded an empty file. The upload instructions also told the model to curl without the returned headers, which an S3 backend rejects when a sha256 was pinned. Co-Authored-By: Claude Opus 5 (1M context) --- .../datasource-toolkit/src/utils/data-uri.ts | 9 +++++-- .../test/utils/data-uri.test.ts | 12 ++++++++++ packages/mcp-server/README.md | 5 ++-- .../mcp-server/src/file-uploads/resolve.ts | 7 ------ .../mcp-server/src/file-uploads/semaphore.ts | 18 ++++---------- .../src/tools/request-file-upload.ts | 2 +- .../test/file-uploads/resolve.test.ts | 16 +++++++++---- .../test/file-uploads/semaphore.test.ts | 24 ++++--------------- 8 files changed, 43 insertions(+), 50 deletions(-) diff --git a/packages/datasource-toolkit/src/utils/data-uri.ts b/packages/datasource-toolkit/src/utils/data-uri.ts index 93dcd99206..120b13496a 100644 --- a/packages/datasource-toolkit/src/utils/data-uri.ts +++ b/packages/datasource-toolkit/src/utils/data-uri.ts @@ -2,6 +2,8 @@ import type { File } from '../interfaces/action'; import { ValidationError } from '../errors'; +const METADATA_KEYS = ['name', 'charset'] as const; + export function isDataUri(value: unknown): value is string { return typeof value === 'string' && value.startsWith('data:'); } @@ -42,10 +44,13 @@ export function parseDataUri(dataUri: string): File { for (const mediaType of mediaTypes) { const index = mediaType.indexOf('='); + const key = index === -1 ? '' : mediaType.substring(0, index); - if (index !== -1) { + // Assigning any key would let "buffer=oops" replace the decoded bytes with a string, or + // "mimeType=..." contradict the header. Only the metadata Forest carries is read back. + if (METADATA_KEYS.includes(key as (typeof METADATA_KEYS)[number])) { try { - result[mediaType.substring(0, index)] = decodeURIComponent(mediaType.substring(index + 1)); + result[key] = decodeURIComponent(mediaType.substring(index + 1)); } catch { throw malformed(); } diff --git a/packages/datasource-toolkit/test/utils/data-uri.test.ts b/packages/datasource-toolkit/test/utils/data-uri.test.ts index 6a1d7de812..ce3a27a476 100644 --- a/packages/datasource-toolkit/test/utils/data-uri.test.ts +++ b/packages/datasource-toolkit/test/utils/data-uri.test.ts @@ -106,6 +106,18 @@ describe('DataUri', () => { expect(() => parseDataUri(value)).toThrow(ValidationError); }); + // Assigning any key would let a model-supplied uri replace the decoded bytes with a string. + it.each([ + ['buffer', 'data:text/plain;buffer=oops;base64,aGk='], + ['mimeType', 'data:text/plain;mimeType=image/png;base64,aGk='], + ])('ignores a %p media type rather than letting it overwrite the parsed file', (_, uri) => { + const file = parseDataUri(uri); + + expect(Buffer.isBuffer(file.buffer)).toBe(true); + expect(file.buffer.toString()).toBe('hi'); + expect(file.mimeType).toBe('text/plain'); + }); + it('still accepts a percent-encoded name', () => { expect(parseDataUri('data:text/plain;name=100%25.pdf;base64,aGk=').name).toBe('100%.pdf'); }); diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index c9a6c17d8a..02b269f753 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -216,8 +216,9 @@ be able to make it: - **Claude Code** and custom agents: works, they have shell or HTTP access. - **Claude Desktop and Claude.ai**: the attached file lands in the code execution sandbox and the - model can `curl -X PUT -T `, but **the sandbox blocks outbound traffic by - default**. The host of `uploadUrl` must be added under *Settings > Capabilities > Code execution + model can `curl -X PUT -T ` — applying every header the tool returned, since a + pinned `sha256` is signed into `x-amz-checksum-sha256` on S3 and the PUT is rejected without it — + but **the sandbox blocks outbound traffic by default**. The host of `uploadUrl` must be added under *Settings > Capabilities > Code execution and file creation > Additional allowed domains*. Without it the upload fails and nothing on the server side can tell you why — so document your bucket's host for your users. diff --git a/packages/mcp-server/src/file-uploads/resolve.ts b/packages/mcp-server/src/file-uploads/resolve.ts index c02e013104..dd14ec5df7 100644 --- a/packages/mcp-server/src/file-uploads/resolve.ts +++ b/packages/mcp-server/src/file-uploads/resolve.ts @@ -84,13 +84,6 @@ async function download( ); }); - // Only a backend that creates empty objects reaches this. - if (buffer.length === 0) { - throw new Error( - `Field "${field}": uploaded file is empty. Did the upload to uploadUrl succeed?`, - ); - } - // Re-checked after download because getSize is advisory, and because the object can be // replaced between the two calls. if (buffer.length > uploads.maxBytes) throw tooLarge(buffer.length); diff --git a/packages/mcp-server/src/file-uploads/semaphore.ts b/packages/mcp-server/src/file-uploads/semaphore.ts index 8494edc9ff..e23d900cf0 100644 --- a/packages/mcp-server/src/file-uploads/semaphore.ts +++ b/packages/mcp-server/src/file-uploads/semaphore.ts @@ -1,29 +1,19 @@ export type RunExclusive = (task: () => Promise) => Promise; -// Queueing beyond this is wasted work: each read is already bounded by downloadTimeoutSeconds, so -// the last waiter of a full queue would be answered around the point the calling client has given -// up anyway. Failing fast tells the model to retry instead of holding it. -const QUEUE_FACTOR = 2; - export default function createSemaphore(rawLimit: number): RunExclusive { // A limit below 1 would queue every task with nothing left to release it, hanging forever. const limit = Math.max(1, rawLimit); - const maxQueued = limit * QUEUE_FACTOR; let active = 0; const queue: Array<() => void> = []; + // The queue is deliberately unbounded: one action submits all its file references at once, so a + // cap would reject a form carrying more of them than the limit allows, every time. Each read is + // bounded by downloadTimeoutSeconds instead, which is what keeps the queue draining. const acquire = () => - new Promise((resolve, reject) => { + new Promise(resolve => { if (active < limit) { active += 1; resolve(); - } else if (queue.length >= maxQueued) { - reject( - new Error( - `Too many uploads are being read at once (${limit} in progress, ${queue.length} ` + - 'waiting). Retry in a moment.', - ), - ); } else { queue.push(resolve); } diff --git a/packages/mcp-server/src/tools/request-file-upload.ts b/packages/mcp-server/src/tools/request-file-upload.ts index 9b47201433..55219194ce 100644 --- a/packages/mcp-server/src/tools/request-file-upload.ts +++ b/packages/mcp-server/src/tools/request-file-upload.ts @@ -63,7 +63,7 @@ Call this whenever getActionForm shows a field of type "File" or "FileList". Nev Workflow: 1. Call this tool with the filename and mimeType. Pass sha256 to pin the upload to that exact content. -2. Upload the raw bytes to the returned uploadUrl, with the returned method and headers, for example "curl -X PUT -T ". The bytes must not pass through this tool or through your own output. +2. Upload the raw bytes to the returned uploadUrl, with the returned method and every returned header — a pinned sha256 is signed into a checksum header, and the upload is rejected if you omit it. The bytes must not pass through this tool or through your own output. 3. Pass the returned fileHandle string as the value of the file field in executeAction. ${UPLOAD_PREREQUISITE} diff --git a/packages/mcp-server/test/file-uploads/resolve.test.ts b/packages/mcp-server/test/file-uploads/resolve.test.ts index 8718cca5b6..c351eed71f 100644 --- a/packages/mcp-server/test/file-uploads/resolve.test.ts +++ b/packages/mcp-server/test/file-uploads/resolve.test.ts @@ -146,12 +146,18 @@ describe('resolveUploadedFileValues', () => { ).rejects.toThrow('above the 10 byte limit'); }); - it('rejects an empty upload with a hint about the PUT step', async () => { + // A missing object is already rejected by download, so zero bytes only ever means the user + // uploaded an empty file — which maxBytes allows. + it('accepts a legitimately empty file', async () => { const storage = makeStorage({ download: jest.fn().mockResolvedValue(Buffer.alloc(0)) }); - await expect( - resolveUploadedFileValues({ document: makeHandle() }, authInfo, makeUploads(storage)), - ).rejects.toThrow('Field "document": uploaded file is empty'); + const resolved = await resolveUploadedFileValues( + { document: makeHandle() }, + authInfo, + makeUploads(storage), + ); + + expect((resolved.document as { buffer: Buffer }).buffer).toHaveLength(0); }); it('rejects content that does not match the sha256 the handle was pinned to', async () => { @@ -216,7 +222,7 @@ describe('resolveUploadedFileValues', () => { }); it('names the field in the error, so the model knows which file to re-upload', async () => { - const storage = makeStorage({ download: jest.fn().mockResolvedValue(Buffer.alloc(0)) }); + const storage = makeStorage({ download: jest.fn().mockRejectedValue(new Error('NoSuchKey')) }); await expect( resolveUploadedFileValues({ invoice: makeHandle() }, authInfo, makeUploads(storage)), diff --git a/packages/mcp-server/test/file-uploads/semaphore.test.ts b/packages/mcp-server/test/file-uploads/semaphore.test.ts index 81143ff214..e9ab6f5b92 100644 --- a/packages/mcp-server/test/file-uploads/semaphore.test.ts +++ b/packages/mcp-server/test/file-uploads/semaphore.test.ts @@ -72,9 +72,8 @@ describe('createSemaphore', () => { let peak = 0; const run = createSemaphore(3); - // 3 running plus a full queue of 6 — the most the semaphore accepts at once. await Promise.all( - Array.from({ length: 9 }, () => + Array.from({ length: 20 }, () => run(async () => { active += 1; peak = Math.max(peak, active); @@ -90,25 +89,12 @@ describe('createSemaphore', () => { expect(active).toBe(0); }); - // Waiting longer than the caller's own timeout is wasted work, so the queue is bounded. - it('refuses work once the queue is full instead of growing it', async () => { + // One action submits every file reference at once, so the queue must absorb them all. + it('serves a burst larger than the limit instead of refusing it', async () => { const run = createSemaphore(2); - const blocker = new Promise(resolve => { - setTimeout(resolve, 50); - }); - - const accepted = Array.from({ length: 6 }, () => run(() => blocker)); - const overflow = run(async () => 'never runs'); - - await expect(overflow).rejects.toThrow('Too many uploads are being read at once'); - await Promise.all(accepted); - }); - - it('accepts work again once the queue drains', async () => { - const run = createSemaphore(1); - await Promise.all(Array.from({ length: 3 }, () => run(async () => 'done'))); + const results = await Promise.all(Array.from({ length: 25 }, (_, i) => run(async () => i))); - await expect(run(async () => 'later')).resolves.toBe('later'); + expect(results).toHaveLength(25); }); }); From 4242b0617d927bbab50148ebf21fd10d8e0c10cb Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 17:03:47 +0200 Subject: [PATCH 18/39] feat(mcp-server): hold uploads in memory when no storage is configured Enabling file uploads meant provisioning a bucket first, so nobody could try the feature without standing up S3 or GCS. The bucket only holds the bytes between the upload and the action, and nothing requires that to be a third party. fileUploads: {} is now enough: the server keeps the objects in memory and serves its own PUT endpoint under ${prefix}/mcp/uploads. makeIsMcpRoute already claims everything under /mcp/, so the embedded mount routes it with no change. It sits ahead of the body parsers, which would otherwise consume the raw stream, and ahead of allowedMethods(['POST']). The upload and the redemption are two requests, so with several replicas or on a serverless runtime one lands where the other did not. That is announced with a warning at startup and named again in the failure, rather than surfacing as a flaky feature. ephemeralMaxTotalBytes bounds the store at 64 MiB. It is absolute on purpose: a multiple of maxBytes would mean raising the per-file limit multiplies what the process can hold, the opposite of what setting it suggests. An oversized body is drained and answered 413 at the end rather than cut mid-stream, which would reach the client as a connection reset. _example drops its disk-backed storage: it existed only because there was no alternative to a bucket. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 - packages/_example/src/forest/agent.ts | 3 +- .../src/forest/local-upload-storage.ts | 123 --------------- packages/mcp-server/CLAUDE.md | 2 +- packages/mcp-server/README.md | 51 +++++-- packages/mcp-server/src/cli.ts | 4 +- .../src/file-uploads/ephemeral-storage.ts | 141 ++++++++++++++++++ packages/mcp-server/src/file-uploads/types.ts | 29 +++- packages/mcp-server/src/server.ts | 38 ++++- .../mcp-server/src/utils/load-file-uploads.ts | 10 +- .../file-uploads/ephemeral-storage.test.ts | Bin 0 -> 6192 bytes .../test/file-uploads/types.test.ts | 21 +++ packages/mcp-server/test/server.test.ts | 39 +++++ .../test/utils/load-file-uploads.test.ts | 14 +- 14 files changed, 324 insertions(+), 153 deletions(-) delete mode 100644 packages/_example/src/forest/local-upload-storage.ts create mode 100644 packages/mcp-server/src/file-uploads/ephemeral-storage.ts create mode 100644 packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts diff --git a/.gitignore b/.gitignore index c2cc03eb6c..59ab18a925 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,6 @@ lerna-debug.log # forest-bff openapi --output default destination openapi.json -# local upload storage of the _example agent -.upload-storage # yarn yarn-error.log diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index afb2b0ce33..e52acab004 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -19,7 +19,6 @@ import customizeReview from './customizations/review'; import customizeSales from './customizations/sale'; import customizeStore from './customizations/store'; import createTypicode from './datasources/typicode'; -import createLocalUploadStorage from './local-upload-storage'; import mongoose, { connectionString } from '../connections/mongoose'; import sequelizeMsSql from '../connections/sequelize-mssql'; import sequelizeMySql from '../connections/sequelize-mysql'; @@ -96,7 +95,7 @@ export default function makeAgent() { }) .mountAiMcpServer({ ...(allowedOAuthClients && { allowedOAuthClients }), - fileUploads: { storage: createLocalUploadStorage() }, + fileUploads: {}, }) .customizeCollection('card', customizeCard) diff --git a/packages/_example/src/forest/local-upload-storage.ts b/packages/_example/src/forest/local-upload-storage.ts deleted file mode 100644 index bca2fca2fa..0000000000 --- a/packages/_example/src/forest/local-upload-storage.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { UploadStorage } from '@forestadmin/mcp-server'; - -import * as fs from 'fs/promises'; -import * as http from 'http'; -import * as path from 'path'; - -/** - * Development-only UploadStorage: stores objects on disk and serves its own PUT endpoint, so the - * MCP file upload flow can be exercised without a cloud bucket. - * - * It does not authenticate the PUT, so the unguessable key is the only thing protecting an - * object. Fine on localhost, never in production — there, hand a real backend to `fileUploads` - * and let it sign the upload URL (see the mcp-server README). - */ -// Slightly above the 20 MiB fileUploads default, so an oversized upload is reported by the -// server's own maxBytes check rather than masked by this one. -const MAX_BODY_BYTES = 25 * 1024 * 1024; - -export default function createLocalUploadStorage( - port = Number(process.env.HTTP_PORT_UPLOAD_STORAGE ?? 3370), -): UploadStorage { - const root = path.join(process.cwd(), '.upload-storage'); - - // The key of a PUT comes from the request URL, so it is caller-controlled. Sanitizing the - // charset is not enough: '.' and '/' are legal in a key, and "../../etc/passwd" would resolve - // outside root. Resolve first, then require containment. - const pathOf = (key: string) => { - const resolved = path.resolve(root, key.replace(/[^\w./-]/g, '_')); - - if (resolved !== root && !resolved.startsWith(root + path.sep)) { - throw new Error(`Key "${key}" resolves outside the storage root`); - } - - return resolved; - }; - - const log = (message: string) => { - // eslint-disable-next-line no-console - console.log(`[local-upload-storage] ${message}`); - }; - - const server = http.createServer((req, res) => { - const rawKey = (req.url ?? '/').replace(/^\/+/, ''); - - if (req.method !== 'PUT' || !rawKey) { - res.writeHead(405).end(); - - return; - } - - const chunks: Buffer[] = []; - let received = 0; - let refused = false; - - // Nothing authenticates this endpoint, so an unbounded body is a way to run the agent out of - // memory. Refuse as soon as the limit is crossed instead of accumulating to the end. - req.on('data', chunk => { - if (refused) return; - - received += (chunk as Buffer).length; - - if (received > MAX_BODY_BYTES) { - refused = true; - log(`refused ${rawKey}: body exceeds ${MAX_BODY_BYTES} bytes`); - res.writeHead(413).end(); - req.destroy(); - - return; - } - - chunks.push(chunk as Buffer); - }); - - req.on('end', () => { - if (refused) return; - - const body = Buffer.concat(chunks); - - // Everything that can throw on a caller-controlled key runs inside this chain: both - // decodeURIComponent, which raises URIError on a lone '%', and pathOf, which rejects a key - // escaping the root. A throw in this listener would be an uncaughtException and take the - // whole agent down instead of failing one request. - Promise.resolve() - .then(async () => { - const destination = pathOf(decodeURIComponent(rawKey)); - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.writeFile(destination, body); - }) - .then(() => { - log(`stored ${rawKey} (${body.length} bytes)`); - res.writeHead(200).end(); - }) - .catch((error: Error) => { - log(`refused ${rawKey}: ${error.message}`); - res.writeHead(400).end(); - }); - }); - - req.on('error', (error: Error) => log(`request failed: ${error.message}`)); - }); - - server.on('error', (error: Error) => { - log(`cannot listen on ${port}: ${error.message}. Set HTTP_PORT_UPLOAD_STORAGE.`); - }); - - server.listen(port, () => { - log(`PUT endpoint on http://localhost:${port}, objects under ${root}`); - }); - - return { - async createUploadUrl({ key }) { - return { url: `http://localhost:${port}/${encodeURIComponent(key)}`, method: 'PUT' }; - }, - async download(key) { - return fs.readFile(pathOf(key)); - }, - async getSize(key) { - const stat = await fs.stat(pathOf(key)).catch(() => undefined); - - return stat?.size; - }, - }; -} diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index b678f068c6..92ba382301 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -16,7 +16,7 @@ Key flows that only make sense across files: - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). -- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. The `requestFileUpload` tool (`src/tools/request-file-upload.ts`, registered only when the option is set) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. +- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. The `requestFileUpload` tool (`src/tools/request-file-upload.ts`, registered only when the option is set) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns at startup. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. ## Commands diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 02b269f753..b03aeb014c 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -68,7 +68,8 @@ yarn start:dev # Development (loads .env file automatically) | `FOREST_AGENT_URL` | No | your environment's back-end URL | URL the MCP server uses to reach the back-end's data layer. Set it when the server runs next to a self-hosted back-end at an internal address (e.g. `http://localhost:3310`), instead of the public URL registered in Forest | | `FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS` | No | `3600` (1 hour) | Maximum lifetime of the OAuth access tokens the server issues (`tokenTtl.accessTokenSeconds`). Minimum `60` | | `FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS` | No | unbounded | Maximum time between two interactive logins (`tokenTtl.refreshTokenSeconds`). Unset, a client that keeps refreshing never signs in again. Minimum `60` | -| `FOREST_MCP_UPLOAD_STORAGE_MODULE` | No | - | Path to a module providing the `fileUploads` options, enabling action file uploads on the standalone server. See [Action File Uploads](#action-file-uploads) | +| `FOREST_MCP_FILE_UPLOADS` | No | - | `true` enables action file uploads with the in-memory store (single instance only). See [Action File Uploads](#action-file-uploads) | +| `FOREST_MCP_UPLOAD_STORAGE_MODULE` | No | - | Path to a module providing the `fileUploads` options, for a real storage backend | #### Example Configuration @@ -182,14 +183,41 @@ the conversation: 2. The client uploads the raw bytes directly to the storage backend, so they never pass through the MCP server or the model. 3. The client passes the handle (`"$uploadedFile:<...>"`) as the field value in `executeAction`. The server downloads the object and hands it to the agent. The model only ever exchanges the small handle. -`requestFileUpload` is registered only when `fileUploads` is set, so a server without a storage backend never advertises it. It is available on the embedded mount too: `agent.mountAiMcpServer({ fileUploads: { storage } })`. +`requestFileUpload` is registered only when `fileUploads` is set, so a server without it never advertises the tool. + +### Nothing to provision + +`fileUploads: {}` is enough to try it. With no `storage`, the server holds the objects in memory and +serves its own upload endpoint under `/mcp/uploads`: + +```typescript +agent.mountAiMcpServer({ fileUploads: {} }); +``` + +> **Single instance only.** The upload and the redemption are two separate requests. With several +> replicas, in cluster mode, or on a serverless runtime (including cloud agents), one of them lands +> on an instance that never saw the other and the action fails — intermittently, which reads as a +> flaky feature rather than a misconfiguration. Objects are also lost on restart. The server logs a +> warning at startup, and the failure names this cause. **Those deployments need a `storage`.** + +`ephemeralMaxTotalBytes` bounds what the in-memory store holds across all pending uploads, 64 MiB by +default. It is deliberately absolute rather than a multiple of `maxBytes`: derived, raising the +per-file limit would multiply what the process can hold. + +### With a storage backend + +Provide `storage` for anything beyond a single instance. Any backend that can pre-authorize an +upload and read the object back works — S3 presigned URLs (below), GCS signed URLs, Azure SAS. The +package has no storage dependency of its own. ### On the standalone server -A storage backend is an object with methods, so unlike every other standalone option it cannot -travel through an environment variable. Point `FOREST_MCP_UPLOAD_STORAGE_MODULE` at a module that -default-exports the options instead — a bad path or a module without a `storage` fails at startup -rather than running with uploads silently disabled: +`FOREST_MCP_FILE_UPLOADS=true` enables the in-memory store, with the same single-instance caveat. + +For a real backend, a storage is an object with methods, so unlike every other standalone option it +cannot travel through an environment variable. Point `FOREST_MCP_UPLOAD_STORAGE_MODULE` at a module +that default-exports the options instead — a bad path, or a module that exports nothing, fails at +startup rather than running with uploads silently disabled: ```javascript // forest-upload-storage.js @@ -227,11 +255,10 @@ whose upload was blocked has the diagnosis in context. ### Trying it locally -`packages/_example` wires the whole flow with no cloud account: `local-upload-storage.ts` is a -disk-backed `UploadStorage` that serves its own PUT endpoint, and the `review` collection carries an -`Attach a document` action with a `File` and a `FileList` field. Start the example agent, connect an -MCP client to it, and ask for that action with a file — the action reports the name, mime type and -byte count it received. +`packages/_example` wires the whole flow with `fileUploads: {}` — no cloud account, no storage code. +Its `review` collection carries an `Attach a document` action with a `File` and a `FileList` field. +Start the example agent, connect an MCP client to it, and ask for that action with a file — the +action reports the name, mime type and byte count it received. ```mermaid sequenceDiagram @@ -307,7 +334,7 @@ const server = new ForestMCPServer({ The other options are `keyPrefix` (default `mcp-uploads/`), `uploadUrlTtlSeconds` (default 15 min), `handleTtlSeconds` (default 45 min, longer than the upload URL so a slow upload still leaves time to run the action), `maxBytes` (default 20 MiB), `maxConcurrentDownloads` (default 5), and -`downloadTimeoutSeconds` (default 15 s). +`downloadTimeoutSeconds` (default 15 s), and `ephemeralMaxTotalBytes` (default 64 MiB, in-memory store only). Lower `downloadTimeoutSeconds` if the clients calling your agent cut requests sooner than that. The whole `executeAction` has to fit inside their timeout: reading the object, encoding it, and running diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index cdb4373221..36c660a8fa 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -9,7 +9,9 @@ const toSeconds = (value?: string) => (value === undefined ? undefined : Number( async function main() { // Loaded before constructing, so a bad module fails at startup like every other option. - const fileUploads = await loadFileUploads(process.env.FOREST_MCP_UPLOAD_STORAGE_MODULE); + const fileUploads = + (await loadFileUploads(process.env.FOREST_MCP_UPLOAD_STORAGE_MODULE)) ?? + (process.env.FOREST_MCP_FILE_UPLOADS === 'true' ? {} : undefined); const server = new ForestMCPServer({ forestServerUrl: process.env.FOREST_SERVER_URL || 'https://api.forestadmin.com', diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts new file mode 100644 index 0000000000..633ea1f241 --- /dev/null +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -0,0 +1,141 @@ +import type { UploadStorage } from './types'; +import type { Logger } from '../server'; +import type { Request, Response, Router } from 'express'; + +import express from 'express'; + +interface StoredObject { + body: Buffer; + expiresAt: number; +} + +/** + * In-memory UploadStorage serving its own PUT endpoint on the MCP server's own origin, used when + * `fileUploads` is enabled without a storage backend. Nothing to provision — and nothing shared: + * objects live in this process only. + * + * The upload and the redemption are two separate requests, so behind several replicas, in cluster + * mode, or on a serverless runtime one lands on an instance that never saw the other. Those + * deployments need a real backend; this one is for a single instance. + */ +export default class EphemeralStorage implements UploadStorage { + private readonly objects = new Map(); + private storedBytes = 0; + private maxBytes = 0; + private maxTotalBytes = 0; + private ttlSeconds = 0; + private publicBaseUrl = ''; + + /** Called once the server knows its own base url, which the constructor cannot yet. */ + configure(options: { + maxBytes: number; + maxTotalBytes: number; + ttlSeconds: number; + publicBaseUrl: string; + }): void { + this.maxBytes = options.maxBytes; + this.maxTotalBytes = options.maxTotalBytes; + this.ttlSeconds = options.ttlSeconds; + this.publicBaseUrl = options.publicBaseUrl.replace(/\/+$/, ''); + } + + async createUploadUrl({ key }: { key: string }): Promise<{ url: string; method: string }> { + return { url: `${this.publicBaseUrl}/${encodeURIComponent(key)}`, method: 'PUT' }; + } + + async download(key: string): Promise { + const stored = this.read(key); + + if (!stored) { + throw new Error( + 'not found in the in-memory store. It only holds objects for the instance that received ' + + 'the upload: behind several replicas or on a serverless runtime, configure a storage ' + + 'backend on the fileUploads option.', + ); + } + + return stored.body; + } + + async getSize(key: string): Promise { + return this.read(key)?.body.length; + } + + createRouter(logger: Logger): Router { + const router = express.Router(); + + router.put('/:key', (req: Request, res: Response) => { + const { key } = req.params; + + this.evictExpired(); + + // Refused before reading a single chunk when the store has no room at all, rather than + // accumulating a body only to drop it. + if (this.storedBytes >= this.maxTotalBytes) { + logger('Warn', `[fileUploads] refused ${key}: the in-memory store is full`); + res.status(507).end(); + + return; + } + + const chunks: Uint8Array[] = []; + let received = 0; + let refused = false; + + req.on('data', chunk => { + if (refused) return; + + received += (chunk as Uint8Array).length; + + if (received > this.maxBytes || this.storedBytes + received > this.maxTotalBytes) { + // Stop keeping the bytes, but keep reading them and answer at the end. Destroying the + // socket or answering now would reach the client as a connection reset instead of a 413, + // since it is still writing. Nothing accumulates either way. + refused = true; + chunks.length = 0; + logger('Warn', `[fileUploads] refused ${key}: over the in-memory store limits`); + } else { + chunks.push(chunk as Uint8Array); + } + }); + + req.on('end', () => { + if (refused) { + res.status(413).end(); + + return; + } + + this.write(key, Buffer.concat(chunks)); + res.status(200).end(); + }); + + req.on('error', () => res.destroy()); + }); + + return router; + } + + private write(key: string, body: Buffer): void { + this.storedBytes -= this.objects.get(key)?.body.length ?? 0; + this.objects.set(key, { body, expiresAt: Date.now() + this.ttlSeconds * 1000 }); + this.storedBytes += body.length; + } + + private read(key: string): StoredObject | undefined { + this.evictExpired(); + + return this.objects.get(key); + } + + private evictExpired(): void { + const now = Date.now(); + + this.objects.forEach((stored, key) => { + if (stored.expiresAt <= now) { + this.objects.delete(key); + this.storedBytes -= stored.body.length; + } + }); + } +} diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index f9b7a79563..807a1c161d 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -31,13 +31,23 @@ export interface UploadStorage { * and the handle format may change to follow the specification once it lands. */ export interface FileUploadsOptions { - storage: UploadStorage; + /** + * Where uploaded objects live between the upload and the action. Omit it and the server keeps + * them in memory, on its own origin — nothing to provision, but only correct for a single + * instance: see the README. + */ + storage?: UploadStorage; keyPrefix?: string; uploadUrlTtlSeconds?: number; /** Must stay longer than uploadUrlTtlSeconds, so a slow upload leaves time to run the action. */ handleTtlSeconds?: number; maxBytes?: number; maxConcurrentDownloads?: number; + /** + * Total size the in-memory store holds across all pending uploads. Defaults to 64 MiB. Has no + * effect once a `storage` is given. + */ + ephemeralMaxTotalBytes?: number; /** * How long a single storage read may take. Keep it well under the request timeout of the * clients calling the agent, so a slow backend fails here rather than being cut mid-flight. @@ -53,6 +63,7 @@ export interface ResolvedFileUploads { handleTtlSeconds: number; maxBytes: number; downloadTimeoutSeconds: number; + ephemeralMaxTotalBytes: number; authSecret: string; limitDownload: RunExclusive; } @@ -63,6 +74,9 @@ const DEFAULT_HANDLE_TTL_SECONDS = 45 * 60; const DEFAULT_MAX_BYTES = 20 * 1024 * 1024; const DEFAULT_MAX_CONCURRENT_DOWNLOADS = 5; const DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = 15; +// Deliberately absolute rather than a multiple of maxBytes: derived, raising the per-file limit +// would multiply what the process can hold, which is the opposite of what setting it suggests. +export const DEFAULT_EPHEMERAL_MAX_TOTAL_BYTES = 64 * 1024 * 1024; function positiveInteger(field: keyof FileUploadsOptions, value: number | undefined): number { if (value === undefined) return undefined; @@ -75,7 +89,7 @@ function positiveInteger(field: keyof FileUploadsOptions, value: number | undefi } export function resolveFileUploads( - options: FileUploadsOptions | undefined, + options: (FileUploadsOptions & { isEphemeral?: boolean }) | undefined, authSecret: string, logger?: Logger, ): ResolvedFileUploads | undefined { @@ -83,6 +97,14 @@ export function resolveFileUploads( if (!options.storage) throw new Error('fileUploads.storage is required.'); + if (options.ephemeralMaxTotalBytes !== undefined && !options.isEphemeral) { + logger?.( + 'Warn', + 'fileUploads.ephemeralMaxTotalBytes only bounds the in-memory store and is ignored when a ' + + 'storage backend is given.', + ); + } + const uploadUrlTtlSeconds = positiveInteger('uploadUrlTtlSeconds', options.uploadUrlTtlSeconds) ?? DEFAULT_UPLOAD_URL_TTL_SECONDS; @@ -109,6 +131,9 @@ export function resolveFileUploads( downloadTimeoutSeconds: positiveInteger('downloadTimeoutSeconds', options.downloadTimeoutSeconds) ?? DEFAULT_DOWNLOAD_TIMEOUT_SECONDS, + ephemeralMaxTotalBytes: + positiveInteger('ephemeralMaxTotalBytes', options.ephemeralMaxTotalBytes) ?? + DEFAULT_EPHEMERAL_MAX_TOTAL_BYTES, authSecret, limitDownload: createSemaphore( positiveInteger('maxConcurrentDownloads', options.maxConcurrentDownloads) ?? diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index d2a57b3180..8ce4e8dcc0 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -24,6 +24,7 @@ import cors from 'cors'; import express from 'express'; import * as http from 'http'; +import EphemeralStorage from './file-uploads/ephemeral-storage'; import { resolveFileUploads } from './file-uploads/types'; import ForestOAuthProvider from './forest-oauth-provider'; import { createForestServerClient } from './http-client'; @@ -197,6 +198,7 @@ export default class ForestMCPServer { private allowedOAuthClients?: string[]; private fileUploadsOptions?: FileUploadsOptions; private fileUploads?: ResolvedFileUploads; + private ephemeralStorage?: EphemeralStorage; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -457,7 +459,26 @@ export default class ForestMCPServer { async buildExpressApp(baseUrl?: URL): Promise { const { envSecret, authSecret } = this.ensureSecretsAreSet(); - this.fileUploads = resolveFileUploads(this.fileUploadsOptions, authSecret, this.logger); + // No backend given: hold the objects here. Correct for one instance only, so it is announced + // rather than silently assumed — behind replicas the redemption lands where the upload did not. + if (this.fileUploadsOptions && !this.fileUploadsOptions.storage) { + this.ephemeralStorage = new EphemeralStorage(); + this.logger( + 'Warn', + 'fileUploads has no storage backend: uploads are held in memory, on this instance only. ' + + 'They are lost on restart, and a deployment with several replicas or a serverless ' + + 'runtime needs a real backend.', + ); + } + + this.fileUploads = resolveFileUploads( + this.fileUploadsOptions && { + ...this.fileUploadsOptions, + ...(this.ephemeralStorage && { storage: this.ephemeralStorage, isEphemeral: true }), + }, + authSecret, + this.logger, + ); await this.fetchCollectionNames(); @@ -535,6 +556,21 @@ export default class ForestMCPServer { // Body parsers MUST come before OAuth handlers because the token handler // expects req.body to be parsed. When proxied from Koa, the body is already // available but Express needs to see it properly. + // Ahead of the body parsers: they would consume the stream for any content type they + // claim, and this endpoint needs the raw body. Also ahead of allowedMethods(['POST']). + if (this.ephemeralStorage && this.fileUploads) { + const uploadsPath = `${prefix}/mcp/uploads`; + + this.ephemeralStorage.configure({ + maxBytes: this.fileUploads.maxBytes, + maxTotalBytes: this.fileUploads.ephemeralMaxTotalBytes, + ttlSeconds: this.fileUploads.handleTtlSeconds, + publicBaseUrl: new URL(uploadsPath, effectiveBaseUrl).href, + }); + + app.use(uploadsPath, this.ephemeralStorage.createRouter(this.logger)); + } + app.use(express.json()); app.use(express.urlencoded({ extended: true })); diff --git a/packages/mcp-server/src/utils/load-file-uploads.ts b/packages/mcp-server/src/utils/load-file-uploads.ts index 2384b00805..c4790d6e6f 100644 --- a/packages/mcp-server/src/utils/load-file-uploads.ts +++ b/packages/mcp-server/src/utils/load-file-uploads.ts @@ -33,15 +33,17 @@ export default async function loadFileUploads( ); } - const exported = loaded.default ?? loaded; + const exported = loaded?.default ?? loaded; const options = (typeof exported === 'function' ? await exported() : exported) as | FileUploadsOptions | undefined; - if (!options?.storage) { + // An object without a storage is legitimate — it selects the in-memory store. Nothing at all is + // a mistake, most likely a module that forgot to export. + if (!options || typeof options !== 'object') { throw new Error( - `FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" must export { storage }, or a function ` + - 'returning it. See the fileUploads section of the mcp-server README.', + `FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" must export the fileUploads options, or a ` + + 'function returning them. See the fileUploads section of the mcp-server README.', ); } diff --git a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..21ca055b1e5fbb71058521e76d19d3d3462c4661 GIT binary patch literal 6192 zcmcIo+j84R65Z!~ML%o`&_)7jS#fsdOI4)E6;~xrN|q`Qsnli&3<*Ra2A&y+QndQE zeb^t{({lkq3RILWugjK9uxBuH`t<2bwQQBPWN%wZcl4cFETpD;nrpSBB+DlFYji#_ zQeR7*y!@=%us^$LwKS$*ApK|9MZ@qUY`FeD>~lvy3S%xxt-3|oZ)2>MV!3{smLEH5 zY}mwft<)Bu2d_D6OSzO<)E}(UVj&+~U|!YoplfR-3Nw+>`oMT|-l>o#*VRm>$@XT+ zKBar~`VHNEMpR2nVbw8hTfPhiqHXb|2O~bK9k$2x8%~~muktHdyzQFYR;qdBaesqT z5ap`DRq=CPEjlgfHKnR$D{~C6w5>$_3ctltTB*&e?IZl0^h=Xp-uyiM~BAV2l9CYy!_1c@voA^9X2zP1Z z>H&|U)!Ie=^+dP@)`U2UW_Xz4?P)RrRm09%ju^7?Ty>yH>W{JK;q-g0`R*+*U2wo- z8+0eCY?`#HYOEmvp(y2=n*}LdEvjapR+TN?cYu#Pss#x{z?6(!EP*Vx73Q|d2eHJQ z!xcgIJL+`3PvCT^3h2OV5-U;lkrpn~A2Gcn$AAPR>Z7{rs$wE&mbJw^A*N1nH_(!~ zP4R}k%&HUM^oNXOcE*iAKYYtuvvgJCR`G}I_d zg+ho(2bhs9f*CSIt5P=Z(F~EpcmT-4cxZv+Zv4DZ0Fyr*c#{pB2Cn!MF1Xs*eVd<14oyJ__I8AtTG~!G#sLRu$t}%T!tfbvT}cS27a3XX1`q_~5C`kLc4VCX z(&Y4q_ZMgHFAq-Mz5n{%`Pso={`2nxaV>K~=#a>BklOIk1I;{R0|2Zr580^^shn=- zRU_(`*N2X(UcmPDGNe*fUrPg$elKiZ{xJKEc^lQ4m0Q>jQhPY4 z-@XMuzH>*#E}Xl1I(s42vaLnV^l3;r_xAK8yQ*^O!X288IzwC`yFq>p8Q{3%9_tKo zx1GNE^DuEAPLKW`gKO=zaR^>a9SCd|-8*jhl{b70t)2+mV8O|}+`~jPnb0XB-SVwD z%>=9pR2EaRO3_MnHFSV$5juR1hb4!%fHr73NB+aA$^<;Ku@ku~DD17bUH)v!_t
~eN`S+)U@4eb6RMfrCcDoni1YtmRf(yp9IPMF` z!jQTl9^>N7t4GLDN6()3z&{?o550ET6cP$1i&%g3A~bSKufyJR;wt1k)+loKXL>u2 zBKcPZ>Ty^3kCzIAm=)s=j8qy}&YoB{yP6Be9hF^QnQV0yJEe)kYC~{F z9DR7?>)0*J5l4H=pahROW#D?B`fmVV@6JbE^5yiaK9GW8D7KL+IUSrx*_Lt~ zGq=%hYCT;RpVQx5KO25M)v^CSg>=IdU$Hjw#)gdLaaq>|AV>v3cO%dT*H#z49bDYY z_c`m2f@>p#xKRZX>sttSY-=-vy6)Ckx#TD5Hy}XPs13zmIX&IlerAxg_CFIoOsQR?bSLOgN- zhU{SJ1_9#D;<3`CVVw$XHkj^cz%37VKs*y%t($;}YVfX7)#!+Q;cN(QbqDE>>S_%w za|s=EY8FCkGSWBnc)7+L0>^b(95lL(q}ECsSC+W$kY^k$QG5>uQV=?K9>;|Pjbae8 zj+?@u5QrFz4QG7u64TU2So;Pr!-TY2G${{?2Gi1XZ{H0hISu;p14eB;d5D+A1OZPb zAaBTkJZM-V9mL@Yckcc>U&+Q6655ZK-}N=G<2R44vL(ir=dR(tK&j0qz=$G@G}&OE zTVtyg#&P|kFuo%~8_%OCV9b*t=lgxmGlNe)aMA7i`Z6R|tjHv4>NIgK0nTn_#_mCa z;YlizNe7Q~?K&(&F2?>@I1^XCn+ZAAucI`5dH{yp%m?)8%ME!-=Ew|uaXuNTkT%{r uoKj|ZHD-C+h4*+vax`6w`|cfEWx|%wZ%i~3E^u}Z9D2{MH|IO@zyAW};+T5? literal 0 HcmV?d00001 diff --git a/packages/mcp-server/test/file-uploads/types.test.ts b/packages/mcp-server/test/file-uploads/types.test.ts index 6f0faadd85..dd54df5002 100644 --- a/packages/mcp-server/test/file-uploads/types.test.ts +++ b/packages/mcp-server/test/file-uploads/types.test.ts @@ -118,4 +118,25 @@ describe('resolveFileUploads', () => { ).not.toThrow(); }); }); + + // The option only bounds the in-memory store, so silently ignoring it would let someone believe + // they had capped their own backend. + it('warns when the ephemeral bound is set alongside a storage backend', () => { + resolveFileUploads({ storage, ephemeralMaxTotalBytes: 1024 }, AUTH_SECRET, logger); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('only bounds the in-memory store'), + ); + }); + + it('stays quiet when it bounds the store it applies to', () => { + resolveFileUploads( + { storage, ephemeralMaxTotalBytes: 1024, isEphemeral: true }, + AUTH_SECRET, + logger, + ); + + expect(logger).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 8888c1b6ec..2c93b41b4f 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3605,6 +3605,45 @@ describe('Logo URL', () => { }); }); +describe('file uploads without a storage backend', () => { + const buildApp = async (logger?: jest.Mock) => + new ForestMCPServer({ + envSecret: 'test-env-secret', + authSecret: 'test-auth-secret', + forestServerUrl: 'https://test.forestadmin.com', + fileUploads: {}, + ...(logger && { logger }), + }).buildExpressApp(new URL('https://agent.example')); + + it('announces that objects are held in memory on this instance only', async () => { + const logger = jest.fn(); + + await buildApp(logger); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('held in memory, on this instance only'), + ); + }); + + // Registered before allowedMethods(['POST']), which would otherwise answer 405. + it('serves the upload endpoint under /mcp/uploads', async () => { + const response = await request(await buildApp()) + .put('/mcp/uploads/mcp-uploads%2Fuuid%2Fa.txt') + .send('hello'); + + expect(response.status).toBe(200); + }); + + it('leaves /mcp itself bearer-protected', async () => { + const response = await request(await buildApp()) + .post('/mcp') + .send({}); + + expect(response.status).toBe(401); + }); +}); + describe('file uploads tool', () => { const storage = { createUploadUrl: jest.fn().mockResolvedValue({ url: 'https://storage.example/put' }), diff --git a/packages/mcp-server/test/utils/load-file-uploads.test.ts b/packages/mcp-server/test/utils/load-file-uploads.test.ts index 960797d034..c4323bf48c 100644 --- a/packages/mcp-server/test/utils/load-file-uploads.test.ts +++ b/packages/mcp-server/test/utils/load-file-uploads.test.ts @@ -59,15 +59,19 @@ describe('loadFileUploads', () => { ); }); - it('rejects a module that exports no storage, rather than starting without uploads', async () => { + // Omitting the storage selects the in-memory store, so it must not be an error. + it('accepts options without a storage', async () => { const file = writeModule(`module.exports = { maxBytes: 10 };`); - await expect(loadFileUploads(file)).rejects.toThrow('must export { storage }'); + await expect(loadFileUploads(file)).resolves.toEqual({ maxBytes: 10 }); }); - it('rejects a function that returns no storage', async () => { - const file = writeModule(`module.exports = () => undefined;`); + it.each([ + ['a function returning nothing', 'module.exports = () => undefined;'], + ['a module exporting null', 'module.exports = null;'], + ])('rejects %s, which is a module that forgot to export', async (_, body) => { + const file = writeModule(body); - await expect(loadFileUploads(file)).rejects.toThrow('must export { storage }'); + await expect(loadFileUploads(file)).rejects.toThrow('must export the fileUploads options'); }); }); From 1810811e736875d6c96b46a047ccbe7cd7136d44 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 17:32:35 +0200 Subject: [PATCH 19/39] fix(mcp-server): harden the in-memory upload store against concurrency and aborts Findings from the review of the ephemeral storage delta: - reserve bytes as they arrive rather than on 'end', so concurrent uploads no longer each weigh their own size against the same stale total - only accept keys handed out by createUploadUrl, so nothing reaching the origin can fill the store under keys of its own - catch the Buffer.concat allocation failure, which was an uncaughtException taking the whole agent down instead of failing one request - log the client-abort path, which was silent, and release its reservation - distinguish a ttl expiry from a key never uploaded: the first is a ttl to raise, the second a deployment to reconsider - throw when the store is used before configure(), instead of reading undefined options - name the module in the loader errors, and separate "cannot find it" from "it ran and failed" --- .../src/file-uploads/ephemeral-storage.ts | 217 ++++++++++++++---- packages/mcp-server/src/file-uploads/types.ts | 5 +- packages/mcp-server/src/server.ts | 1 + .../mcp-server/src/utils/load-file-uploads.ts | 40 +++- .../file-uploads/ephemeral-storage.test.ts | Bin 6192 -> 10638 bytes packages/mcp-server/test/server.test.ts | 6 +- .../test/utils/load-file-uploads.test.ts | 31 ++- 7 files changed, 238 insertions(+), 62 deletions(-) diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index 633ea1f241..422932e9a8 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -9,6 +9,14 @@ interface StoredObject { expiresAt: number; } +interface EphemeralOptions { + maxBytes: number; + maxTotalBytes: number; + ttlSeconds: number; + issuedTtlSeconds: number; + publicBaseUrl: string; +} + /** * In-memory UploadStorage serving its own PUT endpoint on the MCP server's own origin, used when * `fileUploads` is enabled without a storage backend. Nothing to provision — and nothing shared: @@ -20,39 +28,39 @@ interface StoredObject { */ export default class EphemeralStorage implements UploadStorage { private readonly objects = new Map(); + private readonly issued = new Map(); + private readonly expired = new Map(); private storedBytes = 0; - private maxBytes = 0; - private maxTotalBytes = 0; - private ttlSeconds = 0; - private publicBaseUrl = ''; - - /** Called once the server knows its own base url, which the constructor cannot yet. */ - configure(options: { - maxBytes: number; - maxTotalBytes: number; - ttlSeconds: number; - publicBaseUrl: string; - }): void { - this.maxBytes = options.maxBytes; - this.maxTotalBytes = options.maxTotalBytes; - this.ttlSeconds = options.ttlSeconds; - this.publicBaseUrl = options.publicBaseUrl.replace(/\/+$/, ''); + private inFlightBytes = 0; + private options?: EphemeralOptions; + + /** Separate from the constructor because the server only knows its own base url later. */ + configure(options: EphemeralOptions): void { + this.options = options; } async createUploadUrl({ key }: { key: string }): Promise<{ url: string; method: string }> { - return { url: `${this.publicBaseUrl}/${encodeURIComponent(key)}`, method: 'PUT' }; + const { publicBaseUrl, issuedTtlSeconds } = this.settings(); + + // Recorded so the endpoint only accepts keys it handed out. Without this, anything reaching the + // origin could fill the store under keys of its own and deny the feature to everyone else. + this.expire(); + this.issued.set(key, Date.now() + issuedTtlSeconds * 1000); + + return { + url: `${publicBaseUrl.replace(/\/+$/, '')}/${encodeURIComponent(key)}`, + method: 'PUT', + }; } async download(key: string): Promise { const stored = this.read(key); - if (!stored) { - throw new Error( - 'not found in the in-memory store. It only holds objects for the instance that received ' + - 'the upload: behind several replicas or on a serverless runtime, configure a storage ' + - 'backend on the fileUploads option.', - ); - } + if (!stored) throw new Error(this.absenceReason(key)); + + // Dropped on read: the store is small, and keeping consumed objects until their ttl would let + // a handful of redeemed uploads fill it. A handle is single-use against this backend. + this.forget(key); return stored.body; } @@ -64,78 +72,185 @@ export default class EphemeralStorage implements UploadStorage { createRouter(logger: Logger): Router { const router = express.Router(); + // The uploads route is mounted ahead of the request logger, which needs the body parsers this + // one must precede, so each outcome is reported here instead. + const refuse = (res: Response, key: string, status: number, reason: string) => { + logger('Warn', `[fileUploads] refused ${key}: ${reason}`); + res.status(status).json({ error: reason }); + }; + router.put('/:key', (req: Request, res: Response) => { const { key } = req.params; + const { maxBytes, maxTotalBytes } = this.settings(); + + this.expire(); + + if (!this.issued.has(key)) { + refuse(res, key, 404, 'no upload was authorized for this key, or it has expired'); + + return; + } + + // Answered before reading anything when the client declares a body that cannot fit. + const declared = Number(req.headers['content-length']); + + if (Number.isFinite(declared) && declared > maxBytes) { + refuse(res, key, 413, `the file is larger than the ${maxBytes} byte limit`); - this.evictExpired(); + return; + } - // Refused before reading a single chunk when the store has no room at all, rather than - // accumulating a body only to drop it. - if (this.storedBytes >= this.maxTotalBytes) { - logger('Warn', `[fileUploads] refused ${key}: the in-memory store is full`); - res.status(507).end(); + if (this.storedBytes + this.inFlightBytes >= maxTotalBytes) { + refuse(res, key, 507, `the in-memory store is full (${maxTotalBytes} bytes)`); return; } const chunks: Uint8Array[] = []; - let received = 0; - let refused = false; + let reserved = 0; + let refused = ''; + + // Reserved as the bytes arrive, not once the body is complete: concurrent uploads would + // otherwise each weigh their own size against the same stale total and all be admitted. + const release = () => { + this.inFlightBytes -= reserved; + reserved = 0; + }; req.on('data', chunk => { if (refused) return; - received += (chunk as Uint8Array).length; + const { length } = chunk as Uint8Array; - if (received > this.maxBytes || this.storedBytes + received > this.maxTotalBytes) { - // Stop keeping the bytes, but keep reading them and answer at the end. Destroying the - // socket or answering now would reach the client as a connection reset instead of a 413, - // since it is still writing. Nothing accumulates either way. - refused = true; + if (reserved + length > maxBytes) { + refused = `the file is larger than the ${maxBytes} byte limit`; + } else if (this.storedBytes + this.inFlightBytes + length > maxTotalBytes) { + refused = `the in-memory store is full (${maxTotalBytes} bytes)`; + } + + if (refused) { + // Kept reading, but no longer kept: destroying the socket or answering now would reach a + // client that is still writing as a connection reset instead of a 413. chunks.length = 0; - logger('Warn', `[fileUploads] refused ${key}: over the in-memory store limits`); - } else { - chunks.push(chunk as Uint8Array); + release(); + + return; } + + reserved += length; + this.inFlightBytes += length; + chunks.push(chunk as Uint8Array); }); req.on('end', () => { + release(); + if (refused) { - res.status(413).end(); + refuse(res, key, 413, refused); return; } - this.write(key, Buffer.concat(chunks)); - res.status(200).end(); + // A throw here would be an uncaughtException and take the whole server down, not just this + // request: Buffer.concat allocates and can fail under memory pressure. + try { + const body = Buffer.concat(chunks); + + this.write(key, body); + logger('Debug', `[fileUploads] stored ${key} (${body.length} bytes)`); + res.status(200).end(); + } catch (error) { + logger('Error', `[fileUploads] could not store ${key}: ${(error as Error)?.message}`); + res.status(500).json({ error: 'could not store the upload' }); + } }); - req.on('error', () => res.destroy()); + // An aborted upload never reaches 'end', so its reservation would leak and shrink the store + // for good. Idempotent with the release above. + req.on('close', release); + + // The path a client abort takes, which is the most common real failure here — silence would + // leave a 20 MiB upload that vanished with no trace anywhere. + req.on('error', (error: Error) => { + logger( + 'Warn', + `[fileUploads] upload of ${key} failed after ${reserved} bytes: ${error.message}`, + ); + release(); + res.destroy(); + }); }); return router; } + private settings(): EphemeralOptions { + if (!this.options) { + throw new Error('EphemeralStorage was used before configure() — this is a wiring mistake.'); + } + + return this.options; + } + + // An expired object and one that was never uploaded are the same absence to the caller, but the + // advice differs: one is a ttl to raise, the other a deployment to reconsider. + private absenceReason(key: string): string { + const expiredAt = this.expired.get(key); + + if (expiredAt !== undefined) { + const ago = Math.round((Date.now() - expiredAt) / 1000); + + return `expired from the in-memory store ${ago}s ago, after handleTtlSeconds elapsed.`; + } + + return ( + 'not found in the in-memory store. It only holds objects for the instance that received ' + + 'the upload: behind several replicas or on a serverless runtime, configure a storage ' + + 'backend on the fileUploads option.' + ); + } + private write(key: string, body: Buffer): void { - this.storedBytes -= this.objects.get(key)?.body.length ?? 0; - this.objects.set(key, { body, expiresAt: Date.now() + this.ttlSeconds * 1000 }); + this.forget(key); + this.objects.set(key, { body, expiresAt: Date.now() + this.settings().ttlSeconds * 1000 }); this.storedBytes += body.length; + this.issued.delete(key); + this.expired.delete(key); } private read(key: string): StoredObject | undefined { - this.evictExpired(); + this.expire(); return this.objects.get(key); } - private evictExpired(): void { + private forget(key: string): void { + const stored = this.objects.get(key); + + if (stored) { + this.objects.delete(key); + this.storedBytes -= stored.body.length; + } + } + + private expire(): void { const now = Date.now(); this.objects.forEach((stored, key) => { if (stored.expiresAt <= now) { - this.objects.delete(key); - this.storedBytes -= stored.body.length; + this.forget(key); + this.expired.set(key, stored.expiresAt); } }); + + this.issued.forEach((expiresAt, key) => { + if (expiresAt <= now) this.issued.delete(key); + }); + + // Bounded: it only holds keys, and a redemption that never comes is not worth remembering + // longer than the objects themselves. + this.expired.forEach((expiresAt, key) => { + if (expiresAt + this.settings().ttlSeconds * 1000 <= now) this.expired.delete(key); + }); } } diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index 807a1c161d..a2a7c69225 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -78,7 +78,10 @@ const DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = 15; // would multiply what the process can hold, which is the opposite of what setting it suggests. export const DEFAULT_EPHEMERAL_MAX_TOTAL_BYTES = 64 * 1024 * 1024; -function positiveInteger(field: keyof FileUploadsOptions, value: number | undefined): number { +function positiveInteger( + field: keyof FileUploadsOptions, + value: number | undefined, +): number | undefined { if (value === undefined) return undefined; if (!Number.isInteger(value) || value <= 0) { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 8ce4e8dcc0..8c1b5922ba 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -565,6 +565,7 @@ export default class ForestMCPServer { maxBytes: this.fileUploads.maxBytes, maxTotalBytes: this.fileUploads.ephemeralMaxTotalBytes, ttlSeconds: this.fileUploads.handleTtlSeconds, + issuedTtlSeconds: this.fileUploads.uploadUrlTtlSeconds, publicBaseUrl: new URL(uploadsPath, effectiveBaseUrl).href, }); diff --git a/packages/mcp-server/src/utils/load-file-uploads.ts b/packages/mcp-server/src/utils/load-file-uploads.ts index c4790d6e6f..66b331da2f 100644 --- a/packages/mcp-server/src/utils/load-file-uploads.ts +++ b/packages/mcp-server/src/utils/load-file-uploads.ts @@ -2,6 +2,13 @@ import type { FileUploadsOptions } from '../file-uploads/types'; import * as path from 'path'; +// A module is free to throw a non-Error, and reading .message off it yields undefined. +const describe = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +// Attached rather than passed to the constructor: the error cause option needs a newer lib than +// this package targets, while Node itself carries the property fine. +const withCause = (message: string, cause: unknown) => Object.assign(new Error(message), { cause }); + /** * Loads the `fileUploads` options of the standalone server from a module on disk. * @@ -27,16 +34,37 @@ export default async function loadFileUploads( // eslint-disable-next-line global-require, import/no-dynamic-require, @typescript-eslint/no-var-requires loaded = require(resolved); } catch (error) { - throw new Error( - `Cannot load FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" (resolved to ${resolved}): ` + - `${(error as Error).message}`, + // "cannot find it" and "it ran and failed" send the operator to different places, and only the + // first is about the path they configured. + const absent = + (error as NodeJS.ErrnoException)?.code === 'MODULE_NOT_FOUND' && + String((error as Error)?.message).includes(resolved); + + throw withCause( + absent + ? `FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" was not found (resolved to ${resolved}).` + : `FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" failed while loading: ${describe( + error, + )}`, + error, ); } const exported = loaded?.default ?? loaded; - const options = (typeof exported === 'function' ? await exported() : exported) as - | FileUploadsOptions - | undefined; + let options: FileUploadsOptions | undefined; + + try { + options = (typeof exported === 'function' ? await exported() : exported) as FileUploadsOptions; + } catch (error) { + // The sync path above is wrapped; a rejecting factory would otherwise surface with no mention + // of the module it came from. + throw withCause( + `FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" failed while building the options: ${describe( + error, + )}`, + error, + ); + } // An object without a storage is legitimate — it selects the in-memory store. Nothing at all is // a mistake, most likely a module that forgot to export. diff --git a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts index 21ca055b1e5fbb71058521e76d19d3d3462c4661..82f6077b6890de504fa832c99ebe9545d732e4a1 100644 GIT binary patch literal 10638 zcmcgyZFAek5$@;wit9`>1X~HD6xpfemzhYBr=Ce{k1b6;B$?(297#j~4#tZlR@HCo zhyJlXyY~Ws1S#2Ztw}5qI2`VFpMCb(JtkS9%UaZ{LWx`PgI+9DDelC))R`EC;ROFx zWi+W&d8NwH)31`=__Ld$R8{34Q06nnxq0qP)kL|``;{3^GsUWk%4DX3(fVYg zJrRt>^B3awD0{I>%P=?>t#Fr4r9YpbHvR9gDZJtP`0&!*7|XT9wta^(Fi~@{f*weev`3*B6-K z_RgsqGv*3tCIV|S~r)AtJV#CYWK)Ll)g zs!{QI=iZa4zgaZ1G>J}Rr7p_!Sd7Ti)$wEkr{#61Ze&)ZYLZ38#CmJwrVYc;8p2$; z8YnkMWJu%a<|0Mn`PMY~#l^vVNYngbzhEb_xtu7cX zSY>c4FqxqDu}Ml*H)Sp?Dg!usY)utn1;KBVzuc-k(y_XD_v%z5P;zXI)-%5Q&5x{9 z9tX6n&MszZu1j?$qh-(nP8h^Am3pUSdY%C0RlpAcj3!Q>n<4YuUAF**F>-aifl@cB z1%TTbwt{DDbh=@<7~<{0Xo5hnGg}kA(+xlvm}QL3PRv|wG_4u47feDVbucP)lGl}x zpr55`ksk|PCW|EB6W2+-W7?Pq1BMlqT4XRUrnP?Wc+~~dt!T=0PatBJItEDr zBlr6o*aQkaXEaR`Po$WIMLZw1SP*0_71aUWFb->d0z2IcX@a+#`0K-$G&O$7uMekY zdh1<3w^_KG{_Vy2$d2%kBs0cZRU+55h&5tNvB4@;fGR+8;(DoazBEJVkv(9@xH6`N z9k<)h&=6waqa%0HvlGw;USn5$gbOZvb9{=eFT2J9U##P^)J+}>@I)~HO%v)B8xGtQ z)nY~jiO)dmwE{gGmQwL{17g<{=RlpIosg1hFgktn`uyzm#s0~w*WbN*ceekh|NduR zUa3eRbcBo|fV!fkTWT?gwFV%X*+gfFNTnn`PjZ<)y*gy58txm9LnLUOUa1No{ZZD@ z^3Cjb!mZw!yCGSgb-?03EvxX(ENG8ro+jsO6y6U5f%&{Uce>=!^jjZDcA(YvPz{t0PL#?| zkyupx6Et}+`bm~KEM~?cD5=^y)p?E7iob%9H!+*mZMBz0WL`l?Aq8{sB)lb*s>&<( z3d|!#v~2QA1RsfUPMF9QSwrGkiMZ5w0I5+@zB9{)L~61oMWIfGBAz1{;cr$RCSy{b z0ul<=(`GR7022Vp{Uu6BL-6tad=2+LlmHHpySyoTVLdcTxJ5aA~XX9oyUCuG`e)$P$>mic7Oyq)nX3b zcd#RvG3p3VwOCO7OnE#M=k5-WGe?Jm=3B$!9+%o35VnwOMFY?7cF>bZXlg8zXo+Hv z+0Ae3NgO`JKi-@|++HyON{rY>c6bb8XH0*!gW?+QPZ|5RF1@5BrbWgJ5kpcO@( z7<2nrnkGxNlY0CQ1!>e{T`^?`@+D0Nj(#BwK? zx)E58Yw3W{sVTB23h)CyfStwN9w|*sXTyUgwC|vISpj*9F5TEQ7=>ulq+9uQnLsk2 z2V1JlBsI6mUZ06(Pv#={vmZ9b+gzJ4q+Xv-tY#%2LY}&x3n0B*j-*jSP{~@L#sbBG zU=89|LY8F$Q^D@GzhiL>I_nT5b;lwtQ}ng*Jc%GzN6^g3S=39!w6@o1mgCni_Qk(aDDbSjJ0K6|Tigvf}*;(U{Sd;Cp1cFxS@zxW9?V z2mhq{$g8?7iNl2p0(;yF7fcL1jvx#D^Xqz|UtUd**1d=b;vz2mSm6QoHZ`rdhWTZK zj%*2C7Y~UKPTzzj(|^m=P3<1$6yCNSu>1tM{{g;RJF!^T?xce@5*nx9`%?EG^`OzM z)(^W9{^zOd*2?R1WT;qE>g@g#>(zf1K9d#;~P6NtRAIqd$etcFlxm@QZ z#(D>9OQAT>=Wtyxq36(|$V?S0MQ;8LSSiQxn?WVcBKN6}D5>A0{;V&n&2FUPoCRiv=o+}$KLOJzo$AS1Rj?KI(fKk19XuyvNj;Wq!cjnc}j zSN1cN>2hUz5(kLCg+?*N;uA$$oT~ObRj5=JNLf2wg@hkd>lj$XDbzGL8uSoVcf7>~6#~GfRl_9wDD+naQD%jCCJX8`!nZRzV%bX1 zUw}MF@VG(@>!ov#%?KNG+T=c8P1+>N-1d;9{RfuwfY7ED(HnXlU5Z{h4*)sYXyU30 z?G!PW2@Xf8?nVp~ldu$`QxQ`OQ2E380N+-S&0<-f-O%BL2Nrcgsg%bnC`zwEJaoE3 z0Z$Rp)1=(gCShuo3Bigez%_v$p>pmw8f?P{O#nX6pmKjO`i=36)tlT(YBXR#HQ4ZI z9A@~s#Vh(^0nrfi^Bd50?~q;Cz4L)Nbx(rPcV#J8=xoq;1>Dol zCALOtfp5(T{h&j7`E2-W2*Y2raP7IR8E#@%1B08E%rC(&V@rnJy#SC@$Wu`WXrlm1 zMU*ClOF5%rmVvvLs51?*6X`6oex`5W(8)}QqeFD|=uC^-|2WaQQU>%0AuDN_XRluU8sC6&SA}mzd|G{P zmeVcguXGX*7V|DTHcEMp9pbBdYx+HSLP9ymyRTl~sHkuKI_Pm6>(`}T1s#H67(#u`RvrJd zS|)pd!S`?&Z=>Jqunn6JwN5%~v76ZwkGfOQ^y_sxFZV$#b6?xvFr<+$em^}za1egD zbng(9%N7tZOG;}Hpej#OIoF20bA9})IB8G&-gQ$47*(qk4o#|e>dv+w7%^Ks; z2uYv3858f4J$dsl_y_dp#UEky?yzw#GgbYnU)6gxA76f3IqBYP!wxrQH`S_0Qs}_G zRyhR5YUKH;`+m-ekv;cwBp90j0Jk>!wD)fG>)4{PN@ENgz)LPH_>dClv@g-pPfqK@wI_=5W|JMZ33UY@=W_h5Bp z?LqZ&zowb{Jv-^TQ@7mbtE;_F*WZsm2k4L%bh)rBh_z}O;GpraKx2uPTR^RqHjs;W zFwGgYm&_A$XmV`@O`-M>pq;m4jqJ|9@j4gBrf>#H4NcKGQa2t`uB9TmD z`M=#PtUVoHH9oo>qBRXmX&>4XJgk5j;8M`6& zHZ2CX1r;37sL{S;VU(+S;|1D6%iUBM-vUbF9Rg|)6ILs_ewc$!Up}a&WuzT&1JXNN zBPB^sA9ed73t{6C6f(wrAu+DDkV+!Vy#al#gBsyW*z&rwz)(+Uffgizq8Wq(w}y*L j1X0zL-`;KB!c9c^gl8GXtRkQv!EMjoAlGlZFYNC>iK-N_ diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 2c93b41b4f..a78a0ebb0b 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3626,13 +3626,15 @@ describe('file uploads without a storage backend', () => { ); }); - // Registered before allowedMethods(['POST']), which would otherwise answer 405. + // 404 comes from the uploads router itself, for a key it never handed out. A 405 would mean + // allowedMethods(['POST']) claimed the PUT first, and a hang would mean a body parser did. it('serves the upload endpoint under /mcp/uploads', async () => { const response = await request(await buildApp()) .put('/mcp/uploads/mcp-uploads%2Fuuid%2Fa.txt') .send('hello'); - expect(response.status).toBe(200); + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: expect.stringContaining('no upload was authorized') }); }); it('leaves /mcp itself bearer-protected', async () => { diff --git a/packages/mcp-server/test/utils/load-file-uploads.test.ts b/packages/mcp-server/test/utils/load-file-uploads.test.ts index c4323bf48c..f539741395 100644 --- a/packages/mcp-server/test/utils/load-file-uploads.test.ts +++ b/packages/mcp-server/test/utils/load-file-uploads.test.ts @@ -53,12 +53,39 @@ describe('loadFileUploads', () => { }); }); - it('names the module and the resolved path when it cannot be loaded', async () => { + it('names the module and the resolved path when it cannot be found', async () => { await expect(loadFileUploads('./does-not-exist.js')).rejects.toThrow( - /Cannot load FOREST_MCP_UPLOAD_STORAGE_MODULE "\.\/does-not-exist\.js" \(resolved to .+\)/, + /"\.\/does-not-exist\.js" was not found \(resolved to .+\)/, ); }); + // "your path is wrong" and "your module rejected its own config" send the operator elsewhere. + it('separates a module that ran and failed from one that is absent', async () => { + const file = writeModule(`throw new Error('FOREST_S3_BUCKET is not set');`); + + const error = (await loadFileUploads(file).catch((e: Error) => e)) as Error & { + cause?: unknown; + }; + + expect(error.message).toContain('failed while loading: FOREST_S3_BUCKET is not set'); + expect(error.message).not.toContain('was not found'); + expect(error.cause).toBeInstanceOf(Error); + }); + + it('names the module when its factory rejects', async () => { + const file = writeModule(`module.exports = async () => { throw new Error('no bucket'); };`); + + await expect(loadFileUploads(file)).rejects.toThrow( + /failed while building the options: no bucket/, + ); + }); + + it('describes a module that throws something that is not an Error', async () => { + const file = writeModule(`throw 'nope';`); + + await expect(loadFileUploads(file)).rejects.toThrow(/failed while loading: nope/); + }); + // Omitting the storage selects the in-memory store, so it must not be an error. it('accepts options without a storage', async () => { const file = writeModule(`module.exports = { maxBytes: 10 };`); From 5094c9b08c492d75c3f776e771fb7b3df95d5b67 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 17:34:59 +0200 Subject: [PATCH 20/39] fix(mcp-server): accept an in-memory upload that replaces one of the same key The total-bytes check counted the object the upload was about to replace, so a replacement that would fit was refused mid-stream. Dropping the previous body when the request is admitted, rather than once the new one is complete, keeps the check honest without ever holding both. --- .../src/file-uploads/ephemeral-storage.ts | 4 ++++ .../file-uploads/ephemeral-storage.test.ts | Bin 10638 -> 11072 bytes 2 files changed, 4 insertions(+) diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index 422932e9a8..e02e53a32c 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -100,6 +100,10 @@ export default class EphemeralStorage implements UploadStorage { return; } + // Dropped now rather than in write(): counting bytes this upload is about to replace would + // refuse a replacement that fits, and keeping them until 'end' would hold both at once. + this.forget(key); + if (this.storedBytes + this.inFlightBytes >= maxTotalBytes) { refuse(res, key, 507, `the in-memory store is full (${maxTotalBytes} bytes)`); diff --git a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts index 82f6077b6890de504fa832c99ebe9545d732e4a1..ca56f584e205b1d3de90a5b8b95fd2409079d517 100644 GIT binary patch delta 228 zcmeARJ`lEnm8YId0SNT<6+$vn6^c>|auSnMb5rw56!PqmY-6svtivRRO3cwJZ}Ttp~OuvqVEZF*!N4prlwK z5n+=o(5jM*#1e(X9FV58%$yvcl|Va+OY)0SCp)rBO};P1Iaya&VKRrb`%v uu}$vf<(TZx$3A(54DaOkTw;^YaGOp(&SS)?VQ64rF!>If+-3=$GFbp^l}u0o delta 16 XcmX>Q))%~im1lCltnlVjyk)WgIJ*WY From 9cd8e8180cb93e9a84c6ee0e9fc0f40d9b881520 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 22:03:44 +0200 Subject: [PATCH 21/39] test(mcp-server): upload to the url the in-memory store actually hands out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint test asked the route about a path it wrote itself, so nothing checked that the url returned to the client — built from the server base url — resolves to the route, mounted from the prefix. This uploads to that url through the real app and reads the same bytes back. --- packages/mcp-server/test/server.test.ts | 26 +++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index a78a0ebb0b..88d89b7419 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -1,3 +1,4 @@ +import type EphemeralStorage from '../src/file-uploads/ephemeral-storage'; import type * as net from 'net'; import * as http from 'http'; @@ -3606,14 +3607,17 @@ describe('Logo URL', () => { }); describe('file uploads without a storage backend', () => { - const buildApp = async (logger?: jest.Mock) => + const build = (logger?: jest.Mock) => new ForestMCPServer({ envSecret: 'test-env-secret', authSecret: 'test-auth-secret', forestServerUrl: 'https://test.forestadmin.com', fileUploads: {}, ...(logger && { logger }), - }).buildExpressApp(new URL('https://agent.example')); + }); + + const buildApp = async (logger?: jest.Mock) => + build(logger).buildExpressApp(new URL('https://agent.example')); it('announces that objects are held in memory on this instance only', async () => { const logger = jest.fn(); @@ -3637,6 +3641,24 @@ describe('file uploads without a storage backend', () => { expect(response.body).toEqual({ error: expect.stringContaining('no upload was authorized') }); }); + // The url handed to the client is built from the server's own base url, while the route is + // mounted from the prefix. Uploading to the url it actually returns is the only check that the + // two agree — everything else here asks the route about a path the test itself wrote. + it('accepts an upload at the url it hands out, and serves those exact bytes back', async () => { + const server = build(); + const app = await server.buildExpressApp(new URL('https://agent.example')); + const { ephemeralStorage: storage } = server as unknown as { + ephemeralStorage: EphemeralStorage; + }; + const body = Buffer.from('CONTENU-BINAIRE- ÿ'); + + const { url } = await storage.createUploadUrl({ key: 'mcp-uploads/uuid/rapport final;v2.pdf' }); + const response = await request(app).put(new URL(url).pathname).send(body); + + expect(response.status).toBe(200); + await expect(storage.download('mcp-uploads/uuid/rapport final;v2.pdf')).resolves.toEqual(body); + }); + it('leaves /mcp itself bearer-protected', async () => { const response = await request(await buildApp()) .post('/mcp') From 2ea18811f94ab58715c6c46bdeb63031e50fc86a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 22:36:32 +0200 Subject: [PATCH 22/39] fix(mcp-server): name the refused upload first when an object is missing Found running the feature: an upload refused with a 413 leaves nothing behind, and the message the model got sent the reader after a replica problem it did not have. It now names the refusal first, then the per-instance limitation. --- .../src/file-uploads/ephemeral-storage.ts | 9 ++++++--- .../file-uploads/ephemeral-storage.test.ts | Bin 11072 -> 11295 bytes 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index e02e53a32c..844f3fd7fc 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -207,10 +207,13 @@ export default class EphemeralStorage implements UploadStorage { return `expired from the in-memory store ${ago}s ago, after handleTtlSeconds elapsed.`; } + // The refusal case is the one an operator meets first, and pointing it straight at the + // deployment sends it hunting for a replica problem it does not have. return ( - 'not found in the in-memory store. It only holds objects for the instance that received ' + - 'the upload: behind several replicas or on a serverless runtime, configure a storage ' + - 'backend on the fileUploads option.' + 'not found in the in-memory store. Either the upload never completed — a refused one is ' + + 'answered with a 413 — or it reached another instance: this store only holds what this ' + + 'instance received, so several replicas or a serverless runtime need a storage backend on ' + + 'the fileUploads option.' ); } diff --git a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts index ca56f584e205b1d3de90a5b8b95fd2409079d517..f45a5dcdcb3a557a61c97a48fd07b72c3e6fa3b3 100644 GIT binary patch delta 423 zcmZ`#F-k*05G@1)0kISW%|i<2(e-*Z5u z7B<0&+`$_5N&60EkQx;S9wLg;88yd9Q|?2b3RD!41fFWfL{f#%$0s-@WBp=^)+&;x zj0(y_t!ok48=X-wC{2~AO!{Y_4dSN|&*$s)Hl3d2#lP%7MkZWJ;PqI;;mrD&$QKM} zYnv!p@!hz$*L@vqck`Xy?)j+ST@Qw3midWdYTs-+9a>m)x_@=tJlGDYyW?()%^Iq_YD{;nZE!4 delta 179 zcmbOqaUg8NT{+Io5)Jjd#N5>4$@}GHq;(X^GcuDi6f%nyN-|Ovax$}1b1D^*6HALz zCp)r>PyQgwHo2CUfAT#=uE~N5I@bAlKlEl2^ rR3Im@M4>1(IW@B^H3ckHT9A{UIN4W8Zu0?!CT3+K4A~r`a+n From 51f1c672f01c75104f5d2770ab0252c13ff02c3c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 12 Aug 2026 23:24:43 +0200 Subject: [PATCH 23/39] refactor(mcp-server): cut what the in-memory store did not need - parseFileReference returned a one-member discriminated union whose tag was read nowhere; it returns the handle - isEphemeral only existed to warn that an ignored option was ignored, at the cost of a contorted resolveFileUploads signature - the expired map and its second expiry sweep bought one word of precision in an error message; the message names all three causes instead - settings() guarded an invariant the class establishes itself Also replaces the raw NUL byte in the ephemeral storage test with its escape: git marked the file binary, so it was invisible in the pull request diff. --- .../src/file-uploads/ephemeral-storage.ts | 59 ++++-------------- .../src/file-uploads/file-reference.ts | 10 ++- .../mcp-server/src/file-uploads/resolve.ts | 9 ++- packages/mcp-server/src/file-uploads/types.ts | 10 +-- packages/mcp-server/src/server.ts | 2 +- .../file-uploads/ephemeral-storage.test.ts | Bin 11295 -> 10129 bytes .../test/file-uploads/file-reference.test.ts | 5 +- .../test/file-uploads/types.test.ts | 21 ------- 8 files changed, 24 insertions(+), 92 deletions(-) diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index 844f3fd7fc..2e0e59b298 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -29,10 +29,9 @@ interface EphemeralOptions { export default class EphemeralStorage implements UploadStorage { private readonly objects = new Map(); private readonly issued = new Map(); - private readonly expired = new Map(); private storedBytes = 0; private inFlightBytes = 0; - private options?: EphemeralOptions; + private options!: EphemeralOptions; /** Separate from the constructor because the server only knows its own base url later. */ configure(options: EphemeralOptions): void { @@ -40,7 +39,7 @@ export default class EphemeralStorage implements UploadStorage { } async createUploadUrl({ key }: { key: string }): Promise<{ url: string; method: string }> { - const { publicBaseUrl, issuedTtlSeconds } = this.settings(); + const { publicBaseUrl, issuedTtlSeconds } = this.options; // Recorded so the endpoint only accepts keys it handed out. Without this, anything reaching the // origin could fill the store under keys of its own and deny the feature to everyone else. @@ -56,7 +55,14 @@ export default class EphemeralStorage implements UploadStorage { async download(key: string): Promise { const stored = this.read(key); - if (!stored) throw new Error(this.absenceReason(key)); + if (!stored) { + throw new Error( + 'not found in the in-memory store. Either the upload never completed — a refused one is ' + + 'answered with a 413 — or it expired after handleTtlSeconds, or it reached another ' + + 'instance: this store only holds what this instance received, so several replicas or a ' + + 'serverless runtime need a storage backend on the fileUploads option.', + ); + } // Dropped on read: the store is small, and keeping consumed objects until their ttl would let // a handful of redeemed uploads fill it. A handle is single-use against this backend. @@ -81,7 +87,7 @@ export default class EphemeralStorage implements UploadStorage { router.put('/:key', (req: Request, res: Response) => { const { key } = req.params; - const { maxBytes, maxTotalBytes } = this.settings(); + const { maxBytes, maxTotalBytes } = this.options; this.expire(); @@ -188,41 +194,11 @@ export default class EphemeralStorage implements UploadStorage { return router; } - private settings(): EphemeralOptions { - if (!this.options) { - throw new Error('EphemeralStorage was used before configure() — this is a wiring mistake.'); - } - - return this.options; - } - - // An expired object and one that was never uploaded are the same absence to the caller, but the - // advice differs: one is a ttl to raise, the other a deployment to reconsider. - private absenceReason(key: string): string { - const expiredAt = this.expired.get(key); - - if (expiredAt !== undefined) { - const ago = Math.round((Date.now() - expiredAt) / 1000); - - return `expired from the in-memory store ${ago}s ago, after handleTtlSeconds elapsed.`; - } - - // The refusal case is the one an operator meets first, and pointing it straight at the - // deployment sends it hunting for a replica problem it does not have. - return ( - 'not found in the in-memory store. Either the upload never completed — a refused one is ' + - 'answered with a 413 — or it reached another instance: this store only holds what this ' + - 'instance received, so several replicas or a serverless runtime need a storage backend on ' + - 'the fileUploads option.' - ); - } - private write(key: string, body: Buffer): void { this.forget(key); - this.objects.set(key, { body, expiresAt: Date.now() + this.settings().ttlSeconds * 1000 }); + this.objects.set(key, { body, expiresAt: Date.now() + this.options.ttlSeconds * 1000 }); this.storedBytes += body.length; this.issued.delete(key); - this.expired.delete(key); } private read(key: string): StoredObject | undefined { @@ -244,20 +220,11 @@ export default class EphemeralStorage implements UploadStorage { const now = Date.now(); this.objects.forEach((stored, key) => { - if (stored.expiresAt <= now) { - this.forget(key); - this.expired.set(key, stored.expiresAt); - } + if (stored.expiresAt <= now) this.forget(key); }); this.issued.forEach((expiresAt, key) => { if (expiresAt <= now) this.issued.delete(key); }); - - // Bounded: it only holds keys, and a redemption that never comes is not worth remembering - // longer than the objects themselves. - this.expired.forEach((expiresAt, key) => { - if (expiresAt + this.settings().ttlSeconds * 1000 <= now) this.expired.delete(key); - }); } } diff --git a/packages/mcp-server/src/file-uploads/file-reference.ts b/packages/mcp-server/src/file-uploads/file-reference.ts index be364f13a8..c2427e24f2 100644 --- a/packages/mcp-server/src/file-uploads/file-reference.ts +++ b/packages/mcp-server/src/file-uploads/file-reference.ts @@ -6,14 +6,12 @@ */ export const UPLOADED_FILE_PREFIX = '$uploadedFile:'; -export type FileReference = { kind: 'uploadHandle'; handle: string }; - /** - * Isolated so that the file URIs the MCP specification is designing (SEP-2631) can be added - * as another kind without touching the resolution path. + * Isolated so that the file URIs the MCP specification is designing (SEP-2631) can be recognized + * here without touching the resolution path. */ -export default function parseFileReference(value: unknown): FileReference | null { +export default function parseFileReference(value: unknown): string | null { if (typeof value !== 'string' || !value.startsWith(UPLOADED_FILE_PREFIX)) return null; - return { kind: 'uploadHandle', handle: value.slice(UPLOADED_FILE_PREFIX.length) }; + return value.slice(UPLOADED_FILE_PREFIX.length); } diff --git a/packages/mcp-server/src/file-uploads/resolve.ts b/packages/mcp-server/src/file-uploads/resolve.ts index dd14ec5df7..ff7e690e0a 100644 --- a/packages/mcp-server/src/file-uploads/resolve.ts +++ b/packages/mcp-server/src/file-uploads/resolve.ts @@ -19,10 +19,10 @@ function collectReferences( const candidates = Array.isArray(value) ? value : [value]; candidates.forEach(candidate => { - const parsed = parseFileReference(candidate); + const handle = parseFileReference(candidate); - if (parsed && !references.has(candidate as string)) { - references.set(candidate as string, { field, handle: parsed.handle }); + if (handle && !references.has(candidate as string)) { + references.set(candidate as string, { field, handle }); } }); } @@ -146,8 +146,7 @@ export default async function resolveUploadedFileValues( ), ); - const substitute = (value: unknown) => - files.has(value as string) ? files.get(value as string) : value; + const substitute = (value: unknown) => files.get(value as string) ?? value; return Object.fromEntries( Object.entries(values).map(([field, value]) => [ diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index a2a7c69225..a6098fe422 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -92,7 +92,7 @@ function positiveInteger( } export function resolveFileUploads( - options: (FileUploadsOptions & { isEphemeral?: boolean }) | undefined, + options: FileUploadsOptions | undefined, authSecret: string, logger?: Logger, ): ResolvedFileUploads | undefined { @@ -100,14 +100,6 @@ export function resolveFileUploads( if (!options.storage) throw new Error('fileUploads.storage is required.'); - if (options.ephemeralMaxTotalBytes !== undefined && !options.isEphemeral) { - logger?.( - 'Warn', - 'fileUploads.ephemeralMaxTotalBytes only bounds the in-memory store and is ignored when a ' + - 'storage backend is given.', - ); - } - const uploadUrlTtlSeconds = positiveInteger('uploadUrlTtlSeconds', options.uploadUrlTtlSeconds) ?? DEFAULT_UPLOAD_URL_TTL_SECONDS; diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 8c1b5922ba..929a1c77a1 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -474,7 +474,7 @@ export default class ForestMCPServer { this.fileUploads = resolveFileUploads( this.fileUploadsOptions && { ...this.fileUploadsOptions, - ...(this.ephemeralStorage && { storage: this.ephemeralStorage, isEphemeral: true }), + ...(this.ephemeralStorage && { storage: this.ephemeralStorage }), }, authSecret, this.logger, diff --git a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts index f45a5dcdcb3a557a61c97a48fd07b72c3e6fa3b3..c112a8ac39479b930519c2c734df983fca84b403 100644 GIT binary patch delta 267 zcmX|+K}y6x5Jhni0|9YdWu^QPVMO9Y@dU11x^>~EO#axxNqXpR2VIDuUO@T)UO~vt z!+3@{f|Y>QqNt+k|5x>S{c-j6nv8BvySJpt#yO+~AD6Xb1=}*N_a`snh+qgW4I#Il zZAr|XZBl3y1q>Z?csp!0^j_`=BObw`9Yl+T&KwCR>g2Rh&ay7=dx$bCsB1Oo9i`F@ znFq~vn58E@r4C2a-pYe3x+j$K;Ctc!J*C!0R*V-(7T0@Q@p*eE{!G%ii_7us7`8Y2 Wdo30R*+w4Uao(B3vr)(C_vSCH-dU&s delta 1002 zcmaizv2GJV5QZ^|VkJ_7h(scY6oyEEEjd0#Aq5JGlmbyuP$Uu*Y4&_$-zIlEr`;oZuO@Mmc`EWop#jHkHur53jW?LZk{LTn2{kiU zL5hwxJ2~cLC*ZsQ++_zN>eVrD+a$vipB>Ar9291%VCqq?mh2u>4{!N*Muv?|6U+;N zE|YsO_?k@&WGNW0;3&!RE?DH=FLag#xyqJuziQ4Zx9^>yRs>m4(BmN|DzK%HUZAi`4Da0Z=_b|r9uRot?1$l zJh{FvdwaQI)w>ZRREt}0EQft=sFc(lP-%dy9%4xdz)_%8dalUPxN>poeW { }); it('extracts the handle of an upload reference', () => { - expect(parseFileReference('$uploadedFile:a.b.c')).toEqual({ - kind: 'uploadHandle', - handle: 'a.b.c', - }); + expect(parseFileReference('$uploadedFile:a.b.c')).toBe('a.b.c'); }); it.each([ diff --git a/packages/mcp-server/test/file-uploads/types.test.ts b/packages/mcp-server/test/file-uploads/types.test.ts index dd54df5002..6f0faadd85 100644 --- a/packages/mcp-server/test/file-uploads/types.test.ts +++ b/packages/mcp-server/test/file-uploads/types.test.ts @@ -118,25 +118,4 @@ describe('resolveFileUploads', () => { ).not.toThrow(); }); }); - - // The option only bounds the in-memory store, so silently ignoring it would let someone believe - // they had capped their own backend. - it('warns when the ephemeral bound is set alongside a storage backend', () => { - resolveFileUploads({ storage, ephemeralMaxTotalBytes: 1024 }, AUTH_SECRET, logger); - - expect(logger).toHaveBeenCalledWith( - 'Warn', - expect.stringContaining('only bounds the in-memory store'), - ); - }); - - it('stays quiet when it bounds the store it applies to', () => { - resolveFileUploads( - { storage, ephemeralMaxTotalBytes: 1024, isEphemeral: true }, - AUTH_SECRET, - logger, - ); - - expect(logger).not.toHaveBeenCalled(); - }); }); From 4cd35f35abdcf736b6404b09fb2270ccc9099cb8 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 10:21:07 +0200 Subject: [PATCH 24/39] docs(mcp-server): name the two conditions a hosted client needs to upload Testing from Claude Desktop showed the prerequisite was missing the half that actually blocks people: a hosted code execution sandbox runs on its own network, so a localhost agent is unreachable from it no matter what is allowlisted. It also implied the end user can allow the domain themselves, where on Team and Enterprise plans an organization owner may have to. Marks the allowlist path as expected but not yet verified against a public agent, rather than stating it as established. --- packages/mcp-server/README.md | 20 ++++++++++++++----- .../src/tools/request-file-upload.ts | 10 ++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index b03aeb014c..4d44ae34bb 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -242,13 +242,23 @@ backend needs credentials fetched at boot. Step 2 is an ordinary HTTPS request, made by the client, outside the MCP protocol. The client has to be able to make it: -- **Claude Code** and custom agents: works, they have shell or HTTP access. +- **Claude Code** and custom agents: works. The shell runs on the same machine as the developer, so + it reaches a `localhost` agent too — this is the one place the whole flow can be tried end to end + against a local agent. Verified. - **Claude Desktop and Claude.ai**: the attached file lands in the code execution sandbox and the model can `curl -X PUT -T ` — applying every header the tool returned, since a - pinned `sha256` is signed into `x-amz-checksum-sha256` on S3 and the PUT is rejected without it — - but **the sandbox blocks outbound traffic by default**. The host of `uploadUrl` must be added under *Settings > Capabilities > Code execution - and file creation > Additional allowed domains*. Without it the upload fails and nothing on the - server side can tell you why — so document your bucket's host for your users. + pinned `sha256` is signed into `x-amz-checksum-sha256` on S3 and the PUT is rejected without it. + Two conditions, and both are needed: + 1. **`uploadUrl` must be publicly reachable.** That sandbox is hosted and runs on its own + network, so a `localhost` or private address is never reachable from it, whatever else is + configured. An agent running on a developer's machine cannot be tested this way. + 2. **Its host must be allowed for outbound traffic**, under *Settings > Capabilities > Code + execution and file creation > Additional allowed domains* — which on Team and Enterprise plans + an organization owner may have to set, not the end user. + + Both are client-side and outside this server's control, so document your upload host for your + users. Condition 1 is established; condition 2 is expected to be sufficient but is **not yet + verified end to end** against a public agent. The tool states this prerequisite in its description and repeats it in its response, so a model whose upload was blocked has the diagnosis in context. diff --git a/packages/mcp-server/src/tools/request-file-upload.ts b/packages/mcp-server/src/tools/request-file-upload.ts index 55219194ce..798925c620 100644 --- a/packages/mcp-server/src/tools/request-file-upload.ts +++ b/packages/mcp-server/src/tools/request-file-upload.ts @@ -15,10 +15,12 @@ const MAX_FILENAME_LENGTH = 128; const REQUIRED_SCOPE = 'mcp:action'; const UPLOAD_PREREQUISITE = - 'Uploading requires an outbound HTTP request from your environment. In a code execution ' + - 'sandbox, the host of uploadUrl must be allowed for outbound traffic: on Claude Desktop, add ' + - 'it under Settings > Capabilities > Code execution and file creation > Additional allowed ' + - 'domains. A blocked request is a client configuration issue, not an expired handle.'; + 'Uploading requires an outbound HTTP request from your environment. A hosted code execution ' + + 'sandbox runs on its own network, so the host of uploadUrl must be publicly reachable — a ' + + 'localhost or private address can never be reached from there — and allowed for outbound ' + + 'traffic: on Claude Desktop, under Settings > Capabilities > Code execution and file creation > ' + + 'Additional allowed domains, which an organization owner may have to set. A blocked or ' + + 'unreachable request is a client configuration issue, not an expired handle.'; interface RequestFileUploadArgument { filename: string; From b8c461fb178ddc9b2d8f6f851aa7debe8636c236 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 10:37:31 +0200 Subject: [PATCH 25/39] fix(mcp-server): stop claiming a checksum header the default backend never sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things surfaced by uploading by hand. The tool told the model a pinned sha256 "is signed into a checksum header, and the upload is rejected if you omit it". True of an S3 presigned PUT, false of the in-memory store, which returns Content-Type alone — so the description was wrong in the default configuration. The digest was and stays enforced after download; only the claim about how was wrong. A second PUT to the same url answered "no upload was authorized for this key, or it has expired", pointing at a ttl. The real cause is that write() consumes the authorization: the url takes one upload, which is what stops a leaked url from replacing bytes that already landed. That case now answers 409 and says so, and the README states the property instead of leaving it to be discovered. --- packages/mcp-server/README.md | 8 ++++++++ .../mcp-server/src/file-uploads/ephemeral-storage.ts | 9 ++++++++- packages/mcp-server/src/tools/request-file-upload.ts | 4 ++-- .../test/file-uploads/ephemeral-storage.test.ts | 12 ++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 4d44ae34bb..99b48bd060 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -185,6 +185,14 @@ the conversation: `requestFileUpload` is registered only when `fileUploads` is set, so a server without it never advertises the tool. +The upload URL is unauthenticated — the model holds no agent credential, and must not — so the URL +itself is the authorization, as with an S3 presigned PUT. It carries a random uuid, is refused +before a byte is read unless this server issued it, expires with `uploadUrlTtlSeconds`, and serves +nothing but the `PUT`. Against the in-memory store it also **accepts a single upload**: once the +bytes land, a leaked URL can no longer replace them. Writing is not consuming either — redemption +needs the signed handle, which is bound to the user who requested it. Pin `sha256` when the exact +content matters; it is re-verified after download. + ### Nothing to provision `fileUploads: {}` is enough to try it. With no `storage`, the server holds the objects in memory and diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index 2e0e59b298..6bc6f823b9 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -92,7 +92,14 @@ export default class EphemeralStorage implements UploadStorage { this.expire(); if (!this.issued.has(key)) { - refuse(res, key, 404, 'no upload was authorized for this key, or it has expired'); + // write() consumes the authorization, so an object still sitting here means the url was + // already used. Reported apart because a retry is the case an integrator actually hits, + // and "not authorized, or expired" sends them looking at ttls instead. + if (this.objects.has(key)) { + refuse(res, key, 409, 'this upload url was already used; request a fresh one'); + } else { + refuse(res, key, 404, 'no upload was authorized for this key, or it has expired'); + } return; } diff --git a/packages/mcp-server/src/tools/request-file-upload.ts b/packages/mcp-server/src/tools/request-file-upload.ts index 798925c620..f5e0265deb 100644 --- a/packages/mcp-server/src/tools/request-file-upload.ts +++ b/packages/mcp-server/src/tools/request-file-upload.ts @@ -65,12 +65,12 @@ Call this whenever getActionForm shows a field of type "File" or "FileList". Nev Workflow: 1. Call this tool with the filename and mimeType. Pass sha256 to pin the upload to that exact content. -2. Upload the raw bytes to the returned uploadUrl, with the returned method and every returned header — a pinned sha256 is signed into a checksum header, and the upload is rejected if you omit it. The bytes must not pass through this tool or through your own output. +2. Upload the raw bytes to the returned uploadUrl, with the returned method and every returned header. Some backends sign a pinned sha256 into a checksum header and reject the upload without it, so apply the headers as returned rather than assuming which ones matter. A pinned digest is re-verified when the action runs either way. The bytes must not pass through this tool or through your own output. 3. Pass the returned fileHandle string as the value of the file field in executeAction. ${UPLOAD_PREREQUISITE} -The handle expires, so run the upload and the action without a long pause in between.`, +The url accepts one upload: to send different bytes, call this tool again for a fresh destination. The handle expires too, so run the upload and the action without a long pause in between.`, inputSchema: { filename: z .string() diff --git a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts index c112a8ac39..bfbb2f36e9 100644 --- a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts +++ b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts @@ -185,6 +185,18 @@ describe('EphemeralStorage', () => { await expect(storage.getSize('k')).resolves.toBeUndefined(); }); + // Substituting the bytes of an upload that already landed is the one thing a leaked url could + // still do, so the authorization is consumed. A retry must say that, not blame a ttl. + it('names the used url rather than blaming a ttl on a second upload', async () => { + await put('k', Buffer.from('first')); + + const response = await request(app).put('/k').send('second'); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ error: expect.stringContaining('already used') }); + await expect(storage.download('k')).resolves.toEqual(Buffer.from('first')); + }); + it('stops accepting an upload url that was never used in time', async () => { configure({ issuedTtlSeconds: 60 }); await storage.createUploadUrl({ key: 'k' }); From 31fb6162369120b3b8b2a2d145dcc8305e724fff Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 10:40:23 +0200 Subject: [PATCH 26/39] refactor(mcp-server)!: rename requestFileUpload to requestActionFileUpload Nothing in the old name said the tool exists for action file fields; it read like a general upload facility, which is not what it does. Renamed now because a tool name is a client-facing contract: after release it would break anyone who listed it in enabledTools, and the option is still experimental. The title says what it returns rather than restating the name. --- packages/mcp-server/CLAUDE.md | 2 +- packages/mcp-server/README.md | 10 +++++----- packages/mcp-server/src/file-uploads/types.ts | 2 +- packages/mcp-server/src/server.ts | 12 ++++++------ packages/mcp-server/src/tools/execute-action.ts | 2 +- ...-file-upload.ts => request-action-file-upload.ts} | 10 +++++----- packages/mcp-server/test/server.test.ts | 2 +- .../mcp-server/test/tools/execute-action.test.ts | 4 ++-- ...ad.test.ts => request-action-file-upload.test.ts} | 10 +++++----- 9 files changed, 27 insertions(+), 27 deletions(-) rename packages/mcp-server/src/tools/{request-file-upload.ts => request-action-file-upload.ts} (95%) rename packages/mcp-server/test/tools/{request-file-upload.test.ts => request-action-file-upload.test.ts} (97%) diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 92ba382301..0c5043fb4a 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -16,7 +16,7 @@ Key flows that only make sense across files: - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). -- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. The `requestFileUpload` tool (`src/tools/request-file-upload.ts`, registered only when the option is set) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns at startup. +- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`, registered only when the option is set) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns at startup. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. ## Commands diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 99b48bd060..006b566fc9 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -20,7 +20,7 @@ This MCP server provides HTTP REST API access to Forest Admin operations, enabli | `dissociate` | Dissociate records from a relation | | `getActionForm` | Get the form fields for a custom action | | `executeAction` | Execute a custom action | -| `requestFileUpload` | Get a destination to upload a file to, for an action `File` field (only with `fileUploads`) | +| `requestActionFileUpload` | Get a destination to upload a file to, for an action `File` field (only with `fileUploads`) | ## Usage @@ -171,7 +171,7 @@ The minimum for either value is 60 seconds; anything lower is raised to it. An i > **Experimental.** The MCP specification is still designing its own file transfer story > ([SEP-2631](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631)). The -> `UploadStorage` contract is expected to survive, but the `requestFileUpload` tool and the handle +> `UploadStorage` contract is expected to survive, but the `requestActionFileUpload` tool and the handle > format may change to follow the specification once it lands. Actions with **File fields** cannot normally run over MCP. The agent expects file values as data @@ -179,11 +179,11 @@ uris, which would transit the model's context window and exceed most MCP clients The `fileUploads` option enables them through an upload side-channel that keeps the bytes out of the conversation: -1. The client calls the `requestFileUpload` tool with `{ "filename", "mimeType", "sha256"? }` and receives a pre-authorized upload URL plus a signed `fileHandle` string. +1. The client calls the `requestActionFileUpload` tool with `{ "filename", "mimeType", "sha256"? }` and receives a pre-authorized upload URL plus a signed `fileHandle` string. 2. The client uploads the raw bytes directly to the storage backend, so they never pass through the MCP server or the model. 3. The client passes the handle (`"$uploadedFile:<...>"`) as the field value in `executeAction`. The server downloads the object and hands it to the agent. The model only ever exchanges the small handle. -`requestFileUpload` is registered only when `fileUploads` is set, so a server without it never advertises the tool. +`requestActionFileUpload` is registered only when `fileUploads` is set, so a server without it never advertises the tool. The upload URL is unauthenticated — the model holds no agent credential, and must not — so the URL itself is the authorization, as with an S3 presigned PUT. It carries a random uuid, is refused @@ -285,7 +285,7 @@ sequenceDiagram participant Storage as Storage backend participant Agent as Forest Admin agent - Client->>Server: requestFileUpload {filename, mimeType, sha256?} + Client->>Server: requestActionFileUpload {filename, mimeType, sha256?} Server-->>Client: uploadUrl + fileHandle (user-bound JWT) Client->>Storage: PUT raw bytes to uploadUrl Note over Client,Storage: bytes bypass the server and the model diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index a6098fe422..358cdab5f0 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -27,7 +27,7 @@ export interface UploadStorage { /** * @experimental The MCP specification is still designing its own file transfer story - * (SEP-2631). The storage contract is expected to survive, but the `requestFileUpload` tool + * (SEP-2631). The storage contract is expected to survive, but the `requestActionFileUpload` tool * and the handle format may change to follow the specification once it lands. */ export interface FileUploadsOptions { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 929a1c77a1..6efac507d2 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -38,7 +38,7 @@ import declareExecuteActionTool from './tools/execute-action'; import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; -import declareRequestFileUploadTool from './tools/request-file-upload'; +import declareRequestActionFileUploadTool from './tools/request-action-file-upload'; import declareUpdateTool from './tools/update'; import normalizeAgentUrl from './utils/normalize-agent-url'; import normalizeDomainList from './utils/normalize-domain-list'; @@ -94,7 +94,7 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { describeCollection: ['collectionName'], getActionForm: ['collectionName', 'actionName', 'recordIds'], executeAction: ['collectionName', 'actionName', 'recordIds'], - requestFileUpload: ['mimeType'], + requestActionFileUpload: ['mimeType'], associate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordId'], dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], }; @@ -110,7 +110,7 @@ export type ToolName = | 'dissociate' | 'getActionForm' | 'executeAction' - | 'requestFileUpload'; + | 'requestActionFileUpload'; /** * Options for configuring the Forest Admin MCP Server @@ -268,8 +268,8 @@ export default class ForestMCPServer { ...(this.fileUploads ? [ { - name: 'requestFileUpload' as const, - register: () => declareRequestFileUploadTool(mcpServer, ctx), + name: 'requestActionFileUpload' as const, + register: () => declareRequestActionFileUploadTool(mcpServer, ctx), }, ] : []), @@ -310,7 +310,7 @@ export default class ForestMCPServer { 'dissociate', 'getActionForm', 'executeAction', - 'requestFileUpload', + 'requestActionFileUpload', ]; const enabled = new Set(options?.enabledTools ?? allToolNames); diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index c9d9991696..4fec95d218 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -46,7 +46,7 @@ If you call executeAction with missing required fields, it will return an error ctx.fileUploads ? ` -To fill a file field, never inline base64 file content. Call requestFileUpload to get an upload destination, upload the raw bytes there, and pass the returned fileHandle string as the field value.` +To fill a file field, never inline base64 file content. Call requestActionFileUpload to get an upload destination, upload the raw bytes there, and pass the returned fileHandle string as the field value.` : '' }`, inputSchema: argumentShape, diff --git a/packages/mcp-server/src/tools/request-file-upload.ts b/packages/mcp-server/src/tools/request-action-file-upload.ts similarity index 95% rename from packages/mcp-server/src/tools/request-file-upload.ts rename to packages/mcp-server/src/tools/request-action-file-upload.ts index f5e0265deb..492befeddb 100644 --- a/packages/mcp-server/src/tools/request-file-upload.ts +++ b/packages/mcp-server/src/tools/request-action-file-upload.ts @@ -22,7 +22,7 @@ const UPLOAD_PREREQUISITE = 'Additional allowed domains, which an organization owner may have to set. A blocked or ' + 'unreachable request is a client configuration issue, not an expired handle.'; -interface RequestFileUploadArgument { +interface RequestActionFileUploadArgument { filename: string; mimeType: string; sha256?: string; @@ -48,7 +48,7 @@ function normalizeSha256(sha256: string | undefined): string | undefined { throw new Error('sha256 must be the file digest as hex or base64.'); } -export default function declareRequestFileUploadTool( +export default function declareRequestActionFileUploadTool( mcpServer: McpServer, ctx: ToolContext, ): string { @@ -56,9 +56,9 @@ export default function declareRequestFileUploadTool( return registerToolWithLogging( mcpServer, - 'requestFileUpload', + 'requestActionFileUpload', { - title: 'Request a file upload destination', + title: 'Request an upload destination for an action file field', description: `Get a destination to upload a file to, then reference it in an action form field. Call this whenever getActionForm shows a field of type "File" or "FileList". Never inline base64 file content as a field value: it would be far larger than any payload limit. @@ -84,7 +84,7 @@ The url accepts one upload: to send different bytes, call this tool again for a .describe('Optional sha256 digest of the file, hex or base64, to pin the upload.'), }, }, - async (options: RequestFileUploadArgument, extra) => { + async (options: RequestActionFileUploadArgument, extra) => { if (!fileUploads) { throw new Error( 'File uploads are not configured on this server. ' + diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 88d89b7419..fde41c5bb4 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3494,7 +3494,7 @@ describe('enabledTools', () => { 'dissociate', 'getActionForm', 'executeAction', - 'requestFileUpload', + 'requestActionFileUpload', ], }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index e99c0c9039..9b8bab74d0 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -612,7 +612,7 @@ describe('declareExecuteActionTool', () => { fileUploads: fileUploads(), }); - expect(registeredToolConfig.description).toContain('requestFileUpload'); + expect(registeredToolConfig.description).toContain('requestActionFileUpload'); expect(registeredToolConfig.description).toContain('never inline base64'); }); @@ -623,7 +623,7 @@ describe('declareExecuteActionTool', () => { collectionNames: [], }); - expect(registeredToolConfig.description).not.toContain('requestFileUpload'); + expect(registeredToolConfig.description).not.toContain('requestActionFileUpload'); }); it('hands the uploaded file to agent-client, which owns the encoding', async () => { diff --git a/packages/mcp-server/test/tools/request-file-upload.test.ts b/packages/mcp-server/test/tools/request-action-file-upload.test.ts similarity index 97% rename from packages/mcp-server/test/tools/request-file-upload.test.ts rename to packages/mcp-server/test/tools/request-action-file-upload.test.ts index 421729ac71..8f341b87d4 100644 --- a/packages/mcp-server/test/tools/request-file-upload.test.ts +++ b/packages/mcp-server/test/tools/request-action-file-upload.test.ts @@ -8,7 +8,7 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { verifyUploadHandle } from '../../src/file-uploads/handles'; import { resolveFileUploads } from '../../src/file-uploads/types'; -import declareRequestFileUploadTool from '../../src/tools/request-file-upload'; +import declareRequestActionFileUploadTool from '../../src/tools/request-action-file-upload'; const AUTH_SECRET = 'test-auth-secret'; const mockLogger: Logger = jest.fn(); @@ -24,7 +24,7 @@ const authenticatedExtra = { authInfo: { token: 'test-token', scopes: ['mcp:read', 'mcp:action'], extra: { userId: 42 } }, } as unknown as RequestHandlerExtra; -describe('declareRequestFileUploadTool', () => { +describe('declareRequestActionFileUploadTool', () => { let mcpServer: McpServer; let handler: (options: unknown, extra: unknown) => Promise<{ content: { text: string }[] }>; let config: RegisteredToolConfig; @@ -40,7 +40,7 @@ describe('declareRequestFileUploadTool', () => { getSize: jest.fn(), }; - declareRequestFileUploadTool(mcpServer, { + declareRequestActionFileUploadTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, collectionNames: [], @@ -143,7 +143,7 @@ describe('declareRequestFileUploadTool', () => { }); it('falls back to PUT and a Content-Type header when the storage provides neither', async () => { - declareRequestFileUploadTool(mcpServer, { + declareRequestActionFileUploadTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, collectionNames: [], @@ -166,7 +166,7 @@ describe('declareRequestFileUploadTool', () => { }); it('uses the method and headers the storage returns', async () => { - declareRequestFileUploadTool(mcpServer, { + declareRequestActionFileUploadTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, collectionNames: [], From f6ce069b647c88e5d112a0bd869c6c6431081574 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 14:42:05 +0200 Subject: [PATCH 27/39] fix(mcp-server): make the in-memory upload url genuinely single-use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review. Two concurrent PUTs for one issued key both passed the has() check and the later silently overwrote the earlier. The authorization is now consumed at the start of the request, so exactly one gets through. A refused attempt burns the url too, which is the honest reading of single-use — retrying an oversized body against the same url would fail anyway. The issued map also grew without bound: nothing obliges a caller that asked for a destination to ever upload, and only stored bodies counted against ephemeralMaxTotalBytes. Capped at 10 000 outstanding keys, about 2 MB, with the least recent dropped and logged. Refusing to issue instead would let one caller deny the feature to everyone. Also accepts `fileUploads: true` and prefers it in the docs and the example: `{}` was the recommended spelling of the common case and read like a placeholder someone forgot to fill in. --- packages/_example/src/forest/agent.ts | 2 +- packages/agent/src/agent.ts | 11 +++-- packages/mcp-server/README.md | 6 +-- .../src/file-uploads/ephemeral-storage.ts | 44 ++++++++++++++--- packages/mcp-server/src/server.ts | 11 +++-- .../file-uploads/ephemeral-storage.test.ts | 47 ++++++++++++++++++- packages/mcp-server/test/server.test.ts | 2 +- 7 files changed, 100 insertions(+), 23 deletions(-) diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index e52acab004..aa692f1444 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -95,7 +95,7 @@ export default function makeAgent() { }) .mountAiMcpServer({ ...(allowedOAuthClients && { allowedOAuthClients }), - fileUploads: {}, + fileUploads: true, }) .customizeCollection('card', customizeCard) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index e3461977f4..80f1b95c1b 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -57,7 +57,7 @@ export default class Agent extends FrameworkMounter private mcpBasePath?: string; private mcpTokenTtl?: TokenTtlOptions; private mcpAllowedOAuthClients?: string[]; - private mcpFileUploads?: FileUploadsOptions; + private mcpFileUploads?: true | FileUploadsOptions; /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; @@ -265,16 +265,17 @@ export default class Agent extends FrameworkMounter * // Example: only accept approved OAuth client applications, matched by the domain of their * // registered redirect URIs (subdomains included). Other clients get invalid_client. * agent.mountAiMcpServer({ allowedOAuthClients: ['dust.tt'] }); - * // Example: let action File fields be filled over MCP. Experimental, and it needs a storage - * // backend you provide: see the mcp-server README for the UploadStorage contract. - * agent.mountAiMcpServer({ fileUploads: { storage } }); + * // Example: let action File fields be filled over MCP. Experimental. Nothing to provision, + * // but the in-memory store only serves a single instance: see the mcp-server README. + * agent.mountAiMcpServer({ fileUploads: true }); // in-memory, single instance + * agent.mountAiMcpServer({ fileUploads: { storage } }); // a real backend */ mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string; tokenTtl?: TokenTtlOptions; allowedOAuthClients?: string[]; - fileUploads?: FileUploadsOptions; + fileUploads?: true | FileUploadsOptions; }): this { this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 006b566fc9..2687665d21 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -195,11 +195,11 @@ content matters; it is re-verified after download. ### Nothing to provision -`fileUploads: {}` is enough to try it. With no `storage`, the server holds the objects in memory and +`fileUploads: true` is enough to try it. With no `storage`, the server holds the objects in memory and serves its own upload endpoint under `/mcp/uploads`: ```typescript -agent.mountAiMcpServer({ fileUploads: {} }); +agent.mountAiMcpServer({ fileUploads: true }); ``` > **Single instance only.** The upload and the redemption are two separate requests. With several @@ -273,7 +273,7 @@ whose upload was blocked has the diagnosis in context. ### Trying it locally -`packages/_example` wires the whole flow with `fileUploads: {}` — no cloud account, no storage code. +`packages/_example` wires the whole flow with `fileUploads: true` — no cloud account, no storage code. Its `review` collection carries an `Attach a document` action with a `File` and a `FileList` field. Start the example agent, connect an MCP client to it, and ask for that action with a file — the action reports the name, mime type and byte count it received. diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index 6bc6f823b9..e751eb5b2f 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -9,6 +9,11 @@ interface StoredObject { expiresAt: number; } +// Issued keys are cheap next to a body — a few hundred bytes against up to maxBytes — but they +// outlive the request that asked for one, and nothing forces the caller to ever upload. This bounds +// them at about 2 MB, orders of magnitude beyond any real number of pending uploads. +const MAX_OUTSTANDING_KEYS = 10_000; + interface EphemeralOptions { maxBytes: number; maxTotalBytes: number; @@ -33,6 +38,8 @@ export default class EphemeralStorage implements UploadStorage { private inFlightBytes = 0; private options!: EphemeralOptions; + constructor(private readonly logger: Logger) {} + /** Separate from the constructor because the server only knows its own base url later. */ configure(options: EphemeralOptions): void { this.options = options; @@ -44,6 +51,21 @@ export default class EphemeralStorage implements UploadStorage { // Recorded so the endpoint only accepts keys it handed out. Without this, anything reaching the // origin could fill the store under keys of its own and deny the feature to everyone else. this.expire(); + + // Insertion order, so this drops the least recent pending upload. Refusing to issue instead + // would let one caller deny the feature to everyone; whoever is evicted gets a 404 telling + // them to ask again. + if (this.issued.size >= MAX_OUTSTANDING_KEYS) { + const [oldest] = this.issued.keys(); + + this.issued.delete(oldest); + this.logger( + 'Warn', + `[fileUploads] ${MAX_OUTSTANDING_KEYS} upload urls are outstanding: dropped ${oldest}, ` + + 'whose upload never came. Something is requesting destinations without uploading.', + ); + } + this.issued.set(key, Date.now() + issuedTtlSeconds * 1000); return { @@ -75,8 +97,9 @@ export default class EphemeralStorage implements UploadStorage { return this.read(key)?.body.length; } - createRouter(logger: Logger): Router { + createRouter(): Router { const router = express.Router(); + const { logger } = this; // The uploads route is mounted ahead of the request logger, which needs the body parsers this // one must precede, so each outcome is reported here instead. @@ -91,14 +114,22 @@ export default class EphemeralStorage implements UploadStorage { this.expire(); - if (!this.issued.has(key)) { - // write() consumes the authorization, so an object still sitting here means the url was - // already used. Reported apart because a retry is the case an integrator actually hits, - // and "not authorized, or expired" sends them looking at ttls instead. + // Consumed here rather than in write(): two concurrent PUTs for one key would both pass a + // has() check and the later would silently overwrite the earlier. A failed attempt burns the + // url too, which is the honest reading of single-use — retrying needs a fresh destination. + if (!this.issued.delete(key)) { + // An object still sitting here means the url was already used, which is the case an + // integrator actually hits. Reported apart because "or it has expired" sends them looking + // at ttls instead. if (this.objects.has(key)) { refuse(res, key, 409, 'this upload url was already used; request a fresh one'); } else { - refuse(res, key, 404, 'no upload was authorized for this key, or it has expired'); + refuse( + res, + key, + 404, + 'no upload was authorized for this key: it was never issued, already used, or expired', + ); } return; @@ -205,7 +236,6 @@ export default class EphemeralStorage implements UploadStorage { this.forget(key); this.objects.set(key, { body, expiresAt: Date.now() + this.options.ttlSeconds * 1000 }); this.storedBytes += body.length; - this.issued.delete(key); } private read(key: string): StoredObject | undefined { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 6efac507d2..b7e162e386 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -169,7 +169,8 @@ export interface ForestMCPServerOptions { * @experimental Expected to change to follow the MCP file transfer specification once it * lands (SEP-2631). */ - fileUploads?: FileUploadsOptions; + /** `true` enables it with every default; an object to configure it. */ + fileUploads?: true | FileUploadsOptions; } /** @@ -214,7 +215,9 @@ export default class ForestMCPServer { this.allowedOAuthClients = normalizeDomainList(options?.allowedOAuthClients); // Resolved in buildExpressApp, where the auth secret is known to be set. - this.fileUploadsOptions = options?.fileUploads; + // Enabling with no configuration is the common case now that a storage is optional, and + // `fileUploads: {}` reads like a placeholder someone forgot to fill in. + this.fileUploadsOptions = options?.fileUploads === true ? {} : options?.fileUploads; // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -462,7 +465,7 @@ export default class ForestMCPServer { // No backend given: hold the objects here. Correct for one instance only, so it is announced // rather than silently assumed — behind replicas the redemption lands where the upload did not. if (this.fileUploadsOptions && !this.fileUploadsOptions.storage) { - this.ephemeralStorage = new EphemeralStorage(); + this.ephemeralStorage = new EphemeralStorage(this.logger); this.logger( 'Warn', 'fileUploads has no storage backend: uploads are held in memory, on this instance only. ' + @@ -569,7 +572,7 @@ export default class ForestMCPServer { publicBaseUrl: new URL(uploadsPath, effectiveBaseUrl).href, }); - app.use(uploadsPath, this.ephemeralStorage.createRouter(this.logger)); + app.use(uploadsPath, this.ephemeralStorage.createRouter()); } app.use(express.json()); diff --git a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts index bfbb2f36e9..396470f876 100644 --- a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts +++ b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts @@ -34,14 +34,29 @@ describe('EphemeralStorage', () => { beforeEach(() => { jest.useRealTimers(); logger = jest.fn(); - storage = new EphemeralStorage(); + storage = new EphemeralStorage(logger); configure(); app = express(); - app.use('/', storage.createRouter(logger)); + app.use('/', storage.createRouter()); }); describe('createUploadUrl', () => { + // An issued key outlives the request that asked for one, and nothing obliges the caller to + // upload. Cheap next to a body, but unbounded is unbounded. + it('drops the least recent pending upload past its outstanding cap', async () => { + for (let i = 0; i < 10_000; i += 1) { + // eslint-disable-next-line no-await-in-loop + await storage.createUploadUrl({ key: `k${i}` }); + } + + await storage.createUploadUrl({ key: 'one-too-many' }); + + expect(logger).toHaveBeenCalledWith('Warn', expect.stringContaining('dropped k0')); + await expect(request(app).put('/k0').send('x')).resolves.toMatchObject({ status: 404 }); + await expect(request(app).put('/k1').send('x')).resolves.toMatchObject({ status: 200 }); + }); + it('points at the configured origin, with the key encoded as one segment', async () => { const { url, method } = await storage.createUploadUrl({ key: 'mcp-uploads/uuid/a b.pdf' }); @@ -197,6 +212,34 @@ describe('EphemeralStorage', () => { await expect(storage.download('k')).resolves.toEqual(Buffer.from('first')); }); + // Both would pass a has() check and the later would overwrite the earlier, so the + // authorization is consumed at the start of the request rather than once the body is stored. + it('lets one of two concurrent uploads through, not both', async () => { + await storage.createUploadUrl({ key: 'k' }); + + const [a, b] = await Promise.all([ + request(app).put('/k').send('first'), + request(app).put('/k').send('second'), + ]); + const statuses = [a.status, b.status]; + + expect(statuses.filter(status => status === 200)).toHaveLength(1); + expect(statuses.some(status => status === 404 || status === 409)).toBe(true); + // Whichever won, the object is one whole body rather than two interleaved ones. + await expect(storage.download('k').then(String)).resolves.toMatch(/^(first|second)$/); + }); + + // Consuming at the start means a refused attempt burns the url too. That is the honest reading + // of single-use, and retrying an oversized body against the same url would fail anyway. + it('burns the url even when the upload was refused', async () => { + await put('k', Buffer.alloc(2000)); + + const response = await request(app).put('/k').send('x'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: expect.stringContaining('already used, or expired') }); + }); + it('stops accepting an upload url that was never used in time', async () => { configure({ issuedTtlSeconds: 60 }); await storage.createUploadUrl({ key: 'k' }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index fde41c5bb4..126821420d 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3612,7 +3612,7 @@ describe('file uploads without a storage backend', () => { envSecret: 'test-env-secret', authSecret: 'test-auth-secret', forestServerUrl: 'https://test.forestadmin.com', - fileUploads: {}, + fileUploads: true, ...(logger && { logger }), }); From d724e28bd062e61fa9c31a9e7cee2b88f4eaed7b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 14:50:53 +0200 Subject: [PATCH 28/39] feat(mcp-server)!: enable action file uploads by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing had to be provisioned to use them, yet they still had to be switched on — so every agent shipped with File-field actions that could not run over MCP until someone found the option. `fileUploads` now only configures the feature: a storage backend, size limits, ttls. `enabledTools` is the off switch, and it takes everything with it — leaving `requestActionFileUpload` out skips the tool, the upload endpoint and the `executeAction` instructions together, so the server never advertises a tool it did not register. The single-instance warning moves from startup to the first createUploadUrl. Announcing at boot would now reach every agent, including those whose actions have no file field, which is noise rather than a warning. Note what this widens: every agent that mounts the MCP server now serves an unauthenticated PUT endpoint on its own origin. It only accepts keys this server issued, each single-use and expiring, and holds at most ephemeralMaxTotalBytes. --- packages/_example/src/forest/agent.ts | 1 - packages/agent/src/agent.ts | 11 ++--- packages/mcp-server/CLAUDE.md | 2 +- packages/mcp-server/README.md | 20 ++++---- packages/mcp-server/src/cli.ts | 7 ++- .../src/file-uploads/ephemeral-storage.ts | 14 ++++++ packages/mcp-server/src/server.ts | 47 ++++++++++--------- packages/mcp-server/test/server.test.ts | 43 ++++++++++++++--- 8 files changed, 95 insertions(+), 50 deletions(-) diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index aa692f1444..9214c69a50 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -95,7 +95,6 @@ export default function makeAgent() { }) .mountAiMcpServer({ ...(allowedOAuthClients && { allowedOAuthClients }), - fileUploads: true, }) .customizeCollection('card', customizeCard) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 80f1b95c1b..4f0cb89bfe 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -57,7 +57,7 @@ export default class Agent extends FrameworkMounter private mcpBasePath?: string; private mcpTokenTtl?: TokenTtlOptions; private mcpAllowedOAuthClients?: string[]; - private mcpFileUploads?: true | FileUploadsOptions; + private mcpFileUploads?: FileUploadsOptions; /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; @@ -265,17 +265,16 @@ export default class Agent extends FrameworkMounter * // Example: only accept approved OAuth client applications, matched by the domain of their * // registered redirect URIs (subdomains included). Other clients get invalid_client. * agent.mountAiMcpServer({ allowedOAuthClients: ['dust.tt'] }); - * // Example: let action File fields be filled over MCP. Experimental. Nothing to provision, - * // but the in-memory store only serves a single instance: see the mcp-server README. - * agent.mountAiMcpServer({ fileUploads: true }); // in-memory, single instance - * agent.mountAiMcpServer({ fileUploads: { storage } }); // a real backend + * // Example: action File fields work over MCP out of the box, with the files held in memory. + * // Experimental. Point them at a real backend when one instance is not enough. + * agent.mountAiMcpServer({ fileUploads: { storage } }); */ mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string; tokenTtl?: TokenTtlOptions; allowedOAuthClients?: string[]; - fileUploads?: true | FileUploadsOptions; + fileUploads?: FileUploadsOptions; }): this { this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 0c5043fb4a..8b57170ab2 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -16,7 +16,7 @@ Key flows that only make sense across files: - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). -- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`, registered only when the option is set) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns at startup. +- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), on by default: `EphemeralStorage` holds the objects in memory unless `fileUploads.storage` provides a backend. `enabledTools` is the only off switch — leaving `requestActionFileUpload` out skips the tool, the upload endpoint and the `executeAction` instructions together. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns on the first `createUploadUrl` rather than at startup — uploads being on by default, a boot warning would reach agents that have no file field at all. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. ## Commands diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 2687665d21..e84e487e0b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -68,7 +68,6 @@ yarn start:dev # Development (loads .env file automatically) | `FOREST_AGENT_URL` | No | your environment's back-end URL | URL the MCP server uses to reach the back-end's data layer. Set it when the server runs next to a self-hosted back-end at an internal address (e.g. `http://localhost:3310`), instead of the public URL registered in Forest | | `FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS` | No | `3600` (1 hour) | Maximum lifetime of the OAuth access tokens the server issues (`tokenTtl.accessTokenSeconds`). Minimum `60` | | `FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS` | No | unbounded | Maximum time between two interactive logins (`tokenTtl.refreshTokenSeconds`). Unset, a client that keeps refreshing never signs in again. Minimum `60` | -| `FOREST_MCP_FILE_UPLOADS` | No | - | `true` enables action file uploads with the in-memory store (single instance only). See [Action File Uploads](#action-file-uploads) | | `FOREST_MCP_UPLOAD_STORAGE_MODULE` | No | - | Path to a module providing the `fileUploads` options, for a real storage backend | #### Example Configuration @@ -183,7 +182,7 @@ the conversation: 2. The client uploads the raw bytes directly to the storage backend, so they never pass through the MCP server or the model. 3. The client passes the handle (`"$uploadedFile:<...>"`) as the field value in `executeAction`. The server downloads the object and hands it to the agent. The model only ever exchanges the small handle. -`requestActionFileUpload` is registered only when `fileUploads` is set, so a server without it never advertises the tool. +`requestActionFileUpload` follows `enabledTools` like every other tool, so a server that leaves it out never advertises it and never serves the upload endpoint. The upload URL is unauthenticated — the model holds no agent credential, and must not — so the URL itself is the authorization, as with an S3 presigned PUT. It carries a random uuid, is refused @@ -193,15 +192,20 @@ bytes land, a leaked URL can no longer replace them. Writing is not consuming ei needs the signed handle, which is bound to the user who requested it. Pin `sha256` when the exact content matters; it is re-verified after download. -### Nothing to provision +### Nothing to provision, and nothing to switch on -`fileUploads: true` is enough to try it. With no `storage`, the server holds the objects in memory and -serves its own upload endpoint under `/mcp/uploads`: +It is on by default. With no `storage`, the server holds the objects in memory and serves its own +upload endpoint under `/mcp/uploads`. The `fileUploads` option only *configures* that — a +backend, size limits, ttls: ```typescript -agent.mountAiMcpServer({ fileUploads: true }); +agent.mountAiMcpServer(); // in memory, single instance +agent.mountAiMcpServer({ fileUploads: { storage } }); // a real backend ``` +To turn the feature off, leave `requestActionFileUpload` out of `enabledTools`. The upload endpoint +is then never mounted and `executeAction` never mentions it. + > **Single instance only.** The upload and the redemption are two separate requests. With several > replicas, in cluster mode, or on a serverless runtime (including cloud agents), one of them lands > on an instance that never saw the other and the action fails — intermittently, which reads as a @@ -220,7 +224,7 @@ package has no storage dependency of its own. ### On the standalone server -`FOREST_MCP_FILE_UPLOADS=true` enables the in-memory store, with the same single-instance caveat. +Uploads are on with the in-memory store, with the same single-instance caveat. For a real backend, a storage is an object with methods, so unlike every other standalone option it cannot travel through an environment variable. Point `FOREST_MCP_UPLOAD_STORAGE_MODULE` at a module @@ -273,7 +277,7 @@ whose upload was blocked has the diagnosis in context. ### Trying it locally -`packages/_example` wires the whole flow with `fileUploads: true` — no cloud account, no storage code. +`packages/_example` needs no configuration for this — no cloud account, no storage code. Its `review` collection carries an `Attach a document` action with a `File` and a `FileList` field. Start the example agent, connect an MCP client to it, and ask for that action with a file — the action reports the name, mime type and byte count it received. diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 36c660a8fa..29a56fd313 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -8,10 +8,9 @@ import parseToolList from './utils/parse-tool-list'; const toSeconds = (value?: string) => (value === undefined ? undefined : Number(value)); async function main() { - // Loaded before constructing, so a bad module fails at startup like every other option. - const fileUploads = - (await loadFileUploads(process.env.FOREST_MCP_UPLOAD_STORAGE_MODULE)) ?? - (process.env.FOREST_MCP_FILE_UPLOADS === 'true' ? {} : undefined); + // Loaded before constructing, so a bad module fails at startup like every other option. Uploads + // are on without it, held in memory; this only points them at a real backend. + const fileUploads = await loadFileUploads(process.env.FOREST_MCP_UPLOAD_STORAGE_MODULE); const server = new ForestMCPServer({ forestServerUrl: process.env.FOREST_SERVER_URL || 'https://api.forestadmin.com', diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index e751eb5b2f..4f5d4d3367 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -37,6 +37,7 @@ export default class EphemeralStorage implements UploadStorage { private storedBytes = 0; private inFlightBytes = 0; private options!: EphemeralOptions; + private announced = false; constructor(private readonly logger: Logger) {} @@ -48,6 +49,19 @@ export default class EphemeralStorage implements UploadStorage { async createUploadUrl({ key }: { key: string }): Promise<{ url: string; method: string }> { const { publicBaseUrl, issuedTtlSeconds } = this.options; + // Said here rather than at startup: uploads are on by default, so a boot-time warning would + // reach every agent including those whose actions have no file field. This fires when the + // feature is actually used, which is when the limitation starts to matter. + if (!this.announced) { + this.announced = true; + this.logger( + 'Warn', + '[fileUploads] no storage backend: uploaded files are held in memory, on this instance ' + + 'only. They are lost on restart, and a deployment with several replicas or a serverless ' + + 'runtime needs a real backend on the fileUploads option.', + ); + } + // Recorded so the endpoint only accepts keys it handed out. Without this, anything reaching the // origin could fill the store under keys of its own and deny the feature to everyone else. this.expire(); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index b7e162e386..2086d370b7 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -169,8 +169,12 @@ export interface ForestMCPServerOptions { * @experimental Expected to change to follow the MCP file transfer specification once it * lands (SEP-2631). */ - /** `true` enables it with every default; an object to configure it. */ - fileUploads?: true | FileUploadsOptions; + /** + * Action file uploads are on by default, with objects held in memory. This only configures + * them — a `storage` backend, size limits, ttls. Drop `requestActionFileUpload` from + * `enabledTools` to turn the feature off entirely. + */ + fileUploads?: FileUploadsOptions; } /** @@ -215,9 +219,7 @@ export default class ForestMCPServer { this.allowedOAuthClients = normalizeDomainList(options?.allowedOAuthClients); // Resolved in buildExpressApp, where the auth secret is known to be set. - // Enabling with no configuration is the common case now that a storage is optional, and - // `fileUploads: {}` reads like a placeholder someone forgot to fill in. - this.fileUploadsOptions = options?.fileUploads === true ? {} : options?.fileUploads; + this.fileUploadsOptions = options?.fileUploads; // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -462,27 +464,26 @@ export default class ForestMCPServer { async buildExpressApp(baseUrl?: URL): Promise { const { envSecret, authSecret } = this.ensureSecretsAreSet(); - // No backend given: hold the objects here. Correct for one instance only, so it is announced - // rather than silently assumed — behind replicas the redemption lands where the upload did not. - if (this.fileUploadsOptions && !this.fileUploadsOptions.storage) { - this.ephemeralStorage = new EphemeralStorage(this.logger); - this.logger( - 'Warn', - 'fileUploads has no storage backend: uploads are held in memory, on this instance only. ' + - 'They are lost on restart, and a deployment with several replicas or a serverless ' + - 'runtime needs a real backend.', + // On unless the tool was left out of enabledTools, which is the one way to turn it off. Gating + // on that keeps executeAction from advertising an upload tool the server never registered. + if (this.enabledTools.has('requestActionFileUpload')) { + // No backend given: hold the objects here. Correct for one instance only, which + // EphemeralStorage announces the first time something actually asks for a destination — + // warning at boot would tax every agent, including those with no file field at all. + if (!this.fileUploadsOptions?.storage) { + this.ephemeralStorage = new EphemeralStorage(this.logger); + } + + this.fileUploads = resolveFileUploads( + { + ...this.fileUploadsOptions, + ...(this.ephemeralStorage && { storage: this.ephemeralStorage }), + }, + authSecret, + this.logger, ); } - this.fileUploads = resolveFileUploads( - this.fileUploadsOptions && { - ...this.fileUploadsOptions, - ...(this.ephemeralStorage && { storage: this.ephemeralStorage }), - }, - authSecret, - this.logger, - ); - await this.fetchCollectionNames(); const app = express(); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 126821420d..7134c61c59 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3607,22 +3607,42 @@ describe('Logo URL', () => { }); describe('file uploads without a storage backend', () => { - const build = (logger?: jest.Mock) => + const build = (options: Record = {}) => new ForestMCPServer({ envSecret: 'test-env-secret', authSecret: 'test-auth-secret', forestServerUrl: 'https://test.forestadmin.com', - fileUploads: true, - ...(logger && { logger }), + ...options, }); - const buildApp = async (logger?: jest.Mock) => - build(logger).buildExpressApp(new URL('https://agent.example')); + const buildApp = async (options: Record = {}) => + build(options).buildExpressApp(new URL('https://agent.example')); - it('announces that objects are held in memory on this instance only', async () => { + // No option to set: the whole point is that an agent gets this without asking. + it('is enabled without any configuration', async () => { + const response = await request(await buildApp()) + .put('/mcp/uploads/never-issued') + .send('hello'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: expect.stringContaining('no upload was authorized') }); + }); + + // Boot-time would reach every agent, including those whose actions have no file field. + it('stays quiet at startup and announces the single instance on first use', async () => { const logger = jest.fn(); + const server = build({ logger }); + await server.buildExpressApp(new URL('https://agent.example')); + + expect(logger).not.toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('held in memory, on this instance only'), + ); - await buildApp(logger); + const { ephemeralStorage: storage } = server as unknown as { + ephemeralStorage: EphemeralStorage; + }; + await storage.createUploadUrl({ key: 'k' }); expect(logger).toHaveBeenCalledWith( 'Warn', @@ -3630,6 +3650,15 @@ describe('file uploads without a storage backend', () => { ); }); + // enabledTools is the off switch, and it has to take the endpoint with it. + it('serves no upload endpoint when the tool is not enabled', async () => { + const app = await buildApp({ enabledTools: ['describeCollection', 'list'] }); + + const response = await request(app).put('/mcp/uploads/anything').send('hello'); + + expect(response.status).toBe(405); + }); + // 404 comes from the uploads router itself, for a key it never handed out. A 405 would mean // allowedMethods(['POST']) claimed the PUT first, and a hang would mean a body parser did. it('serves the upload endpoint under /mcp/uploads', async () => { From 24e0547ddd1316932bed13c6761a9d8926bc014a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 15:56:31 +0200 Subject: [PATCH 29/39] docs(mcp-server): stop naming a settings path the user may not have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prerequisite sent the model to "Settings > Capabilities > Code execution and file creation > Additional allowed domains". That panel does not exist in every Claude Desktop build — on a managed workspace the code execution capability runs while its network setting is not exposed to the user at all, so the text pointed at a menu they cannot open. It now names the requirement — the host must be publicly reachable and allowed for outbound traffic in that environment — and says the setting may be an administrator one. The README tells integrators to give their users the host to get allowed rather than a menu path. --- packages/mcp-server/README.md | 7 ++++--- .../src/tools/request-action-file-upload.ts | 13 ++++++++----- .../test/tools/request-action-file-upload.test.ts | 6 +++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index e84e487e0b..0daf8173eb 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -264,9 +264,10 @@ be able to make it: 1. **`uploadUrl` must be publicly reachable.** That sandbox is hosted and runs on its own network, so a `localhost` or private address is never reachable from it, whatever else is configured. An agent running on a developer's machine cannot be tested this way. - 2. **Its host must be allowed for outbound traffic**, under *Settings > Capabilities > Code - execution and file creation > Additional allowed domains* — which on Team and Enterprise plans - an organization owner may have to set, not the end user. + 2. **Its host must be allowed for outbound traffic** in that sandbox. Where this is configured + differs between clients and versions — and on a managed workspace the setting may not be + exposed to the end user at all, in which case only an administrator can unblock the upload. + Do not promise your users a menu path; give them the host to get allowed. Both are client-side and outside this server's control, so document your upload host for your users. Condition 1 is established; condition 2 is expected to be sufficient but is **not yet diff --git a/packages/mcp-server/src/tools/request-action-file-upload.ts b/packages/mcp-server/src/tools/request-action-file-upload.ts index 492befeddb..6bb2c81685 100644 --- a/packages/mcp-server/src/tools/request-action-file-upload.ts +++ b/packages/mcp-server/src/tools/request-action-file-upload.ts @@ -14,13 +14,16 @@ const SHA256_BASE64_PATTERN = /^[A-Za-z0-9+/]{43}=$/; const MAX_FILENAME_LENGTH = 128; const REQUIRED_SCOPE = 'mcp:action'; +// Names the requirement rather than a menu path: the setting that satisfies it is not in the same +// place in every client, and on a managed workspace the end user may have no access to it at all. const UPLOAD_PREREQUISITE = 'Uploading requires an outbound HTTP request from your environment. A hosted code execution ' + - 'sandbox runs on its own network, so the host of uploadUrl must be publicly reachable — a ' + - 'localhost or private address can never be reached from there — and allowed for outbound ' + - 'traffic: on Claude Desktop, under Settings > Capabilities > Code execution and file creation > ' + - 'Additional allowed domains, which an organization owner may have to set. A blocked or ' + - 'unreachable request is a client configuration issue, not an expired handle.'; + 'sandbox runs on its own network and reaches only allowed hosts, so the host of uploadUrl must ' + + 'be publicly reachable — a localhost or private address never is from there — and allowed for ' + + 'outbound traffic in that environment. Where that is configured depends on the client, and on a ' + + 'managed workspace it may be an administrator setting rather than a user one. A blocked or ' + + 'unreachable request is a client configuration issue, not an expired handle: report it as such ' + + 'instead of requesting another destination.'; interface RequestActionFileUploadArgument { filename: string; diff --git a/packages/mcp-server/test/tools/request-action-file-upload.test.ts b/packages/mcp-server/test/tools/request-action-file-upload.test.ts index 8f341b87d4..549e1ba0ba 100644 --- a/packages/mcp-server/test/tools/request-action-file-upload.test.ts +++ b/packages/mcp-server/test/tools/request-action-file-upload.test.ts @@ -78,10 +78,10 @@ describe('declareRequestActionFileUploadTool', () => { expect(config.description).toContain('Never inline base64 file content'); }); - it('documents the outbound request prerequisite, including the Desktop allowlist', () => { + it('documents the outbound request prerequisite without promising where to configure it', () => { setup(); - expect(config.description).toContain('Additional allowed domains'); + expect(config.description).toContain('allowed for outbound traffic in that environment'); }); }); @@ -110,7 +110,7 @@ describe('declareRequestActionFileUploadTool', () => { const response = await call({ filename: 'report.pdf', mimeType: 'application/pdf' }); - expect(response.prerequisite).toContain('Additional allowed domains'); + expect(response.prerequisite).toContain('allowed for outbound traffic in that environment'); }); it('asks the storage for a key under the configured prefix', async () => { From bb62c8044a1752797620c51bbb09008256bf50e2 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 16:08:47 +0200 Subject: [PATCH 30/39] docs(mcp-server): drop cloud agents from the single-instance warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named as a motivating case for the storage backend, and it is no longer one: Forest Cloud is being wound down. The warning still names serverless runtimes and multiple replicas, which is what remains — and both are the deployments a self-hosted agent chooses deliberately rather than inherits. --- packages/mcp-server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 0daf8173eb..7edebb63f2 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -207,7 +207,7 @@ To turn the feature off, leave `requestActionFileUpload` out of `enabledTools`. is then never mounted and `executeAction` never mentions it. > **Single instance only.** The upload and the redemption are two separate requests. With several -> replicas, in cluster mode, or on a serverless runtime (including cloud agents), one of them lands +> replicas, in cluster mode, or on a serverless runtime, one of them lands > on an instance that never saw the other and the action fails — intermittently, which reads as a > flaky feature rather than a misconfiguration. Objects are also lost on restart. The server logs a > warning at startup, and the failure names this cause. **Those deployments need a `storage`.** From 63062ecf205951696c15e9eca653a2fa93f164cb Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 17:18:19 +0200 Subject: [PATCH 31/39] fix(agent-client)!: keep getType() on the wire form, collapse it apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalizing `['String']` to `'StringList'` inside `getType()` changed what two API responses publish, not just a type: agent-bff (read by the frontend) and workflow-executor (read by the workflow editor) both put `field.getType()` straight into their form-field payload. The normalization existed only to satisfy their declared `getType(): string`, which was the part that was wrong — the runtime had always emitted the array for list fields. So `getType()` returns the wire value again and `getTypeName()` carries the collapsed name, used where a name is what is wanted: dispatching to the file encoder, and the type this server reports to a model — `getActionForm` has to say `FileList`, since the upload tool's own description tells the model to look for a field of that type. Both consumers' declared types now say what they always emitted. --- .../src/action/action-form-mapper.ts | 3 ++- .../src/action/agent-action-client.ts | 3 ++- .../src/action-fields/action-field.ts | 7 +++++- .../src/action-fields/field-form-states.ts | 2 +- .../src/action-fields/field-getter.ts | 14 +++++++++--- .../test/action-fields/file-value.test.ts | 7 ++++-- packages/mcp-server/CLAUDE.md | 2 +- .../mcp-server/src/tools/get-action-form.ts | 22 ++++++++++++++++--- .../workflow-executor/src/ports/agent-port.ts | 3 ++- 9 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/agent-bff/src/action/action-form-mapper.ts b/packages/agent-bff/src/action/action-form-mapper.ts index 56fe55e018..adfd79d4ad 100644 --- a/packages/agent-bff/src/action/action-form-mapper.ts +++ b/packages/agent-bff/src/action/action-form-mapper.ts @@ -5,7 +5,8 @@ const ENUM_TYPE = 'Enum'; export interface ActionFormFieldResponse { name: string; - type: string; + /** Verbatim from the agent, so a list type is `['String']` rather than `'StringList'`. */ + type: string | [string]; value: unknown; isRequired: boolean; enumValues?: string[] | null; diff --git a/packages/agent-bff/src/action/agent-action-client.ts b/packages/agent-bff/src/action/agent-action-client.ts index c0695961e0..cd597ef960 100644 --- a/packages/agent-bff/src/action/agent-action-client.ts +++ b/packages/agent-bff/src/action/agent-action-client.ts @@ -7,7 +7,8 @@ import createAgentHttpRequester from '../agent/create-agent-http-requester'; export interface ActionFormField { getName(): string; - getType(): string; + /** A list type is the array the agent sent, `['String']`, not `'StringList'`. */ + getType(): string | [string]; getValue(): unknown; isRequired(): boolean | undefined; } diff --git a/packages/agent-client/src/action-fields/action-field.ts b/packages/agent-client/src/action-fields/action-field.ts index cd5e13cfba..cee6ed924e 100644 --- a/packages/agent-client/src/action-fields/action-field.ts +++ b/packages/agent-client/src/action-fields/action-field.ts @@ -1,4 +1,5 @@ import type FieldFormStates from './field-form-states'; +import type { PlainField } from './types'; export default abstract class ActionField { private readonly fieldsFormStates: FieldFormStates; @@ -17,10 +18,14 @@ export default abstract class ActionField { return this.name; } - getType(): string { + getType(): PlainField['type'] { return this.field?.getType(); } + getTypeName(): string { + return this.field?.getTypeName(); + } + getValue() { return this.field?.getValue(); } diff --git a/packages/agent-client/src/action-fields/field-form-states.ts b/packages/agent-client/src/action-fields/field-form-states.ts index c011ef4f5f..d0297ff391 100644 --- a/packages/agent-client/src/action-fields/field-form-states.ts +++ b/packages/agent-client/src/action-fields/field-form-states.ts @@ -71,7 +71,7 @@ export default class FieldFormStates { const field = this.getField(name); if (!field) throw new Error(`Field "${name}" not found in action "${this.actionName}"`); - field.getPlainField().value = encodeFileFieldValue(field.getType(), value, name); + field.getPlainField().value = encodeFileFieldValue(field.getTypeName(), value, name); const fieldHasHook = field.getPlainField().hook; diff --git a/packages/agent-client/src/action-fields/field-getter.ts b/packages/agent-client/src/action-fields/field-getter.ts index a66942a155..cf182910fe 100644 --- a/packages/agent-client/src/action-fields/field-getter.ts +++ b/packages/agent-client/src/action-fields/field-getter.ts @@ -19,9 +19,17 @@ export default class FieldGetter { return this.plainField.field; } - // Agents emit list types as ['File'] / ['String'] but loadChanges echoes plainField back to - // them verbatim, so the wire shape is normalized here for dispatch rather than in place. - getType(): string { + /** Exactly what the agent sent: a list type is the array `['File']`, not `'FileList'`. */ + getType(): PlainField['type'] { + return this.plainField.type; + } + + /** + * The same type as a single name, `['File']` becoming `'FileList'`. For dispatching on the type + * and for reporting it to a reader; never for anything that goes back to an agent, which echoes + * `plainField` verbatim through loadChanges and matches only the array form. + */ + getTypeName(): string { const { type } = this.plainField; return Array.isArray(type) ? `${type[0]}List` : type; diff --git a/packages/agent-client/test/action-fields/file-value.test.ts b/packages/agent-client/test/action-fields/file-value.test.ts index 18bd0750f0..13af88a019 100644 --- a/packages/agent-client/test/action-fields/file-value.test.ts +++ b/packages/agent-client/test/action-fields/file-value.test.ts @@ -169,10 +169,13 @@ describe('file values in action forms', () => { }); describe('type normalization', () => { - it('exposes the wire array form as a canonical list type', async () => { + // getType() feeds API responses in agent-bff and workflow-executor, so it has to stay the + // value the agent sent. Only getTypeName() collapses it, and only for dispatch and display. + it('keeps getType() on the wire form and offers the collapsed name apart', async () => { await setupFields([{ field: 'attachments', type: ['File'] }]); - expect(fieldFormStates.getField('attachments')?.getType()).toBe('FileList'); + expect(fieldFormStates.getField('attachments')?.getType()).toEqual(['File']); + expect(fieldFormStates.getField('attachments')?.getTypeName()).toBe('FileList'); }); it('keeps the wire form in the payload echoed back to the agent', async () => { diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 8b57170ab2..7669698916 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -16,7 +16,7 @@ Key flows that only make sense across files: - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). -- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), on by default: `EphemeralStorage` holds the objects in memory unless `fileUploads.storage` provides a backend. `enabledTools` is the only off switch — leaving `requestActionFileUpload` out skips the tool, the upload endpoint and the `executeAction` instructions together. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns on the first `createUploadUrl` rather than at startup — uploads being on by default, a boot warning would reach agents that have no file field at all. +- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), on by default: `EphemeralStorage` holds the objects in memory unless `fileUploads.storage` provides a backend. `enabledTools` is the only off switch — leaving `requestActionFileUpload` out skips the tool, the upload endpoint and the `executeAction` instructions together. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. `FieldGetter.getType()` returns the wire value (`['File']`) because agent-bff and workflow-executor put it straight into API responses; `getTypeName()` is the collapsed `'FileList'`, for dispatch and for what this server reports to a model. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context — and withholds them from `tryToSetFields` so a change hook fired by another field never reads `.buffer` off a handle string. `download` is **not** consume-on-read: `resolve.ts` fetches every reference before `setFields` and `execute`, so a later failure must leave the objects retryable. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns on the first `createUploadUrl` rather than at startup — uploads being on by default, a boot warning would reach agents that have no file field at all. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. ## Commands diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index 656b0fafc3..71cc611667 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -1,6 +1,7 @@ import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import parseFileReference from '../file-uploads/file-reference'; import { createActionArgumentShape } from '../utils/action-helpers'; import { buildClientWithActions } from '../utils/agent-caller'; import registerToolWithLogging from '../utils/tool-with-logging'; @@ -23,6 +24,21 @@ function toAllowedValue(option: unknown): { value: string | number | null; label return { value: option as string | number, label: String(option) }; } +// Setting a field that declares a change hook posts every field value to the agent, and a change +// hook reading `.buffer` off a handle string throws — a 500 on the very sequence executeAction's +// description prescribes. Handles are not resolved on this path on purpose (the bytes would land +// back in the model's context), so they are withheld from the hook instead: the field reads as +// unset, which is what it was before the model chose a destination for it. +function withoutFileReferences(values: Record): Record { + const kept = Object.entries(values).filter(([, value]) => { + const candidates = Array.isArray(value) ? value : [value]; + + return !candidates.some(candidate => parseFileReference(candidate)); + }); + + return Object.fromEntries(kept); +} + export default function declareGetActionFormTool(mcpServer: McpServer, ctx: ToolContext): string { const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = createActionArgumentShape(collectionNames); @@ -66,7 +82,7 @@ The response includes: let skippedFields: string[] = []; if (options.values) { - skippedFields = await action.tryToSetFields(options.values); + skippedFields = await action.tryToSetFields(withoutFileReferences(options.values)); } const fields = action.getFields(); @@ -87,13 +103,13 @@ The response includes: const description = field.getPlainField()?.description; const baseField = { name: field.getName(), - type: field.getType(), + type: field.getTypeName(), value: field.getValue(), isRequired: field.isRequired() ?? false, ...(description ? { description } : {}), }; - if (field.getType() === 'Enum') { + if (field.getTypeName() === 'Enum') { const enumField = action.getEnumField(field.getName()); return { ...baseField, enumValues: enumField.getOptions() ?? null }; diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 1245532a93..085e938984 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -61,7 +61,8 @@ export type GetActionFormQuery = { // get-action-form tool's field shape. export type ActionFormField = { name: string; - type: string; + /** Verbatim from the agent, so a list type is `['String']` rather than `'StringList'`. */ + type: string | [string]; value?: unknown; isRequired: boolean; enumValues?: string[]; From a961a954af2263babc4d89cce77c766ab69ad4a2 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 17:18:41 +0200 Subject: [PATCH 32/39] fix(mcp-server): survive a retry, and stop trusting a broken storage module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review, in order of what a user would hit. **A retry after any post-download failure lost every file.** `download()` deleted the object on read, while `resolveUploadedFileValues` fetches every reference *before* `setFields` and `execute` — the sequence the tool's own description tells the model to retry. A mistyped field name or a throwing hook therefore left the second attempt reporting that uploads had failed when they had succeeded. Nothing is consumed on read now; `expire()` and `ephemeralMaxTotalBytes` reclaim instead. The upload url stays single-use, which is a different property, enforced on the issued key. **A change hook on an action with a File field returned a 500.** On the `getActionForm` path the handle reached the hook, which read `.buffer` off a string. File references are withheld from `tryToSetFields` there, so the field reads as unset — what it was before the model chose a destination. **A storage module that exports a broken storage booted as if configured** and silently ran the in-memory store, putting a deliberately replicated deployment on the one backend that cannot serve it. The three methods are duck-typed when the key is present, naming the missing one; omitting `storage` entirely stays the documented way to ask for the in-memory store. **A data uri without `;base64` decoded to garbage.** `Buffer.from(x, 'base64')` skips what it cannot read rather than throwing, so `data:text/plain,hello` became 3 bytes of nonsense and reached the end user as a corrupt file, reported as a success. Smaller, same review: `getSize` failures are wrapped like `download`'s (a missing object is the likeliest failure of the flow, and it was reaching the model as a bare SDK string); an unusable handle names its field instead of `jwt expired`; the upload 404 admits another instance may have issued the key; `maxBytes` above `ephemeralMaxTotalBytes` warns instead of advertising a size every upload of which is refused; accented filenames survive sanitizing; building a second app no longer swaps the store under the first one's urls; and the semaphore's unreachable `Math.max(1, …)` is gone, with the test that only covered it. The README's claims about consume-on-read, change hooks and what `maxConcurrentDownloads` bounds after a timeout are corrected rather than left describing the old behaviour. --- .../datasource-toolkit/src/utils/data-uri.ts | 6 ++++ packages/mcp-server/README.md | 11 ++++-- .../src/file-uploads/ephemeral-storage.ts | 12 ++++--- .../mcp-server/src/file-uploads/resolve.ts | 31 ++++++++++++----- .../mcp-server/src/file-uploads/semaphore.ts | 4 +-- packages/mcp-server/src/file-uploads/types.ts | 11 +++--- packages/mcp-server/src/server.ts | 34 +++++++++++++------ .../src/tools/request-action-file-upload.ts | 4 ++- .../mcp-server/src/utils/load-file-uploads.ts | 23 +++++++++++-- .../file-uploads/ephemeral-storage.test.ts | 5 +-- .../test/file-uploads/semaphore.test.ts | 7 ---- packages/mcp-server/test/server.test.ts | 10 ++++++ .../test/tools/get-action-form.test.ts | 30 ++++++++++++++-- .../test/utils/load-file-uploads.test.ts | 15 ++++++++ 14 files changed, 155 insertions(+), 48 deletions(-) diff --git a/packages/datasource-toolkit/src/utils/data-uri.ts b/packages/datasource-toolkit/src/utils/data-uri.ts index 120b13496a..0f217fc67b 100644 --- a/packages/datasource-toolkit/src/utils/data-uri.ts +++ b/packages/datasource-toolkit/src/utils/data-uri.ts @@ -40,6 +40,12 @@ export function parseDataUri(dataUri: string): File { const [header, data] = dataUri.substring(5).split(','); const [mimeType, ...mediaTypes] = header.split(';'); + + // Buffer.from(x, 'base64') never throws: it skips what it cannot read and stops at padding. So a + // uri whose payload is not base64 at all — `data:text/plain,hello` — would decode to plausible + // garbage and reach the end user as a corrupt file, reported as a success. + if (!mediaTypes.includes('base64')) throw malformed(); + const result = { mimeType, buffer: Buffer.from(data, 'base64') }; for (const mediaType of mediaTypes) { diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 7edebb63f2..56d8322805 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -371,12 +371,17 @@ A few properties matter in production. - When the client sends `sha256` (hex or base64), the upload URL is pinned to that digest and the digest is checked again on the downloaded bytes at redemption. Content substituted after an upload URL leak cannot be redeemed. - **`maxBytes` does not bound memory on its own.** A pre-authorized upload URL cannot always cap the object size, so the limit is enforced at redemption: before downloading when `getSize` reports a size, and only after the bytes are in memory when it returns `undefined`. Implement `getSize` whenever the backend can answer it cheaply. - **`maxConcurrentDownloads` bounds concurrent downloads, not peak memory.** All the files one `executeAction` call references are held together until the call completes, so a form with N file fields holds up to N × `maxBytes` whatever the concurrency limit is. Size `maxBytes` against the number of file fields your actions declare. -- The server never deletes objects. Configure a lifecycle rule on the storage backend, for example deleting objects under `keyPrefix` after one day. +- **A timed-out read is abandoned, not cancelled.** `UploadStorage.download` takes no `AbortSignal`, so when `downloadTimeoutSeconds` fires the concurrency slot is freed while the underlying read keeps running. Against a backend slower than that timeout the number of reads in flight can therefore exceed `maxConcurrentDownloads`. Keep `downloadTimeoutSeconds` low so a slow backend fails fast instead of accumulating. +- The server never deletes objects, and does not delete them once redeemed either: `executeAction` downloads every reference before it sets fields or runs, so consuming on read would leave a model retrying after any later failure with handles whose objects are gone, told the upload failed when it had not. Configure a lifecycle rule on the storage backend, for example deleting objects under `keyPrefix` after one day; the in-memory store reclaims on `handleTtlSeconds` and on its own total. Only `executeAction` resolves handles. `getActionForm` echoes field values back to the model, so a handle stays a handle there: resolving it would put the file content back into the model's context. -A file field that declares a change hook therefore sends the unresolved handle to that hook, which -receives it as a plain string rather than a parsed file. + +**Change hooks never see a handle.** On the `getActionForm` path, file references are withheld from +`tryToSetFields`, so a hook fired by another field reads the file field as unset rather than as a +string it would call `.buffer` on. On the `executeAction` path the handles are already resolved, so +a hook receives the file as the data uri it expects. Either way a hook never has to know this +side-channel exists. ## API Endpoints diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index 4f5d4d3367..805648408b 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -100,10 +100,11 @@ export default class EphemeralStorage implements UploadStorage { ); } - // Dropped on read: the store is small, and keeping consumed objects until their ttl would let - // a handful of redeemed uploads fill it. A handle is single-use against this backend. - this.forget(key); - + // Deliberately NOT dropped on read. executeAction downloads every reference before it sets + // fields or runs, so any later failure — a mistyped field name, a throwing hook — would leave + // the model retrying with handles whose objects are gone, told the upload failed when it did + // not. expire() and maxTotalBytes reclaim instead. The upload url stays single-use; that is a + // different property, enforced on the issued key. return stored.body; } @@ -142,7 +143,8 @@ export default class EphemeralStorage implements UploadStorage { res, key, 404, - 'no upload was authorized for this key: it was never issued, already used, or expired', + 'no upload was authorized for this key: it was never issued, already used, or ' + + 'expired — or it was issued by another instance, which cannot be seen from here', ); } diff --git a/packages/mcp-server/src/file-uploads/resolve.ts b/packages/mcp-server/src/file-uploads/resolve.ts index ff7e690e0a..3b18c36944 100644 --- a/packages/mcp-server/src/file-uploads/resolve.ts +++ b/packages/mcp-server/src/file-uploads/resolve.ts @@ -57,13 +57,21 @@ async function download( `Field "${field}": uploaded file is ${bytes} bytes, above the ${uploads.maxBytes} byte limit`, ); - // A pre-authorized upload URL cannot always cap the object size, so the limit is enforced - // here, before the bytes are read whenever the backend can report a size. + // A pre-authorized upload URL cannot always cap the object size, so the limit is enforced here, + // before the bytes are read whenever the backend can report a size. Wrapped like the download + // below: a backend that reports size from object metadata rejects on a missing key, which is the + // likeliest failure of the whole flow — the upload was blocked. Raw, that reaches the model as a + // bare SDK string with no field name and no diagnosis. const size = await withTimeout( `Field "${field}": reading the size of the uploaded file`, uploads.downloadTimeoutSeconds, uploads.storage.getSize(claims.key), - ); + ).catch((error: Error) => { + throw new Error( + `Field "${field}": could not read the uploaded file. ` + + `Did the upload to uploadUrl succeed? (${error.message})`, + ); + }); if (typeof size === 'number' && Number.isFinite(size) && size > uploads.maxBytes) { throw tooLarge(size); @@ -129,11 +137,18 @@ export default async function resolveUploadedFileValues( // Verified before acquiring a download slot: it is pure CPU, and it gates everything // expensive, so a batch of forged handles is rejected instead of queueing behind the limit. - const verified = [...references].map(([reference, { field, handle }]) => ({ - field, - reference, - claims: verifyUploadHandle(handle, userId, uploads.authSecret), - })); + const verified = [...references].map(([reference, { field, handle }]) => { + try { + return { field, reference, claims: verifyUploadHandle(handle, userId, uploads.authSecret) }; + } catch (error) { + // Reaching the model as a bare "jwt expired" names no field, and with several file fields in + // one form it names none of them. + throw new Error( + `Field "${field}": this file handle is not usable (${(error as Error).message}). ` + + 'Call requestActionFileUpload again and upload the file to the new destination.', + ); + } + }); const files = new Map( await Promise.all( diff --git a/packages/mcp-server/src/file-uploads/semaphore.ts b/packages/mcp-server/src/file-uploads/semaphore.ts index e23d900cf0..c452862a2c 100644 --- a/packages/mcp-server/src/file-uploads/semaphore.ts +++ b/packages/mcp-server/src/file-uploads/semaphore.ts @@ -1,8 +1,6 @@ export type RunExclusive = (task: () => Promise) => Promise; -export default function createSemaphore(rawLimit: number): RunExclusive { - // A limit below 1 would queue every task with nothing left to release it, hanging forever. - const limit = Math.max(1, rawLimit); +export default function createSemaphore(limit: number): RunExclusive { let active = 0; const queue: Array<() => void> = []; diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index 358cdab5f0..c328a9db7a 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -106,6 +106,11 @@ export function resolveFileUploads( const handleTtlSeconds = positiveInteger('handleTtlSeconds', options.handleTtlSeconds) ?? DEFAULT_HANDLE_TTL_SECONDS; + const maxBytes = positiveInteger('maxBytes', options.maxBytes) ?? DEFAULT_MAX_BYTES; + const ephemeralMaxTotalBytes = + positiveInteger('ephemeralMaxTotalBytes', options.ephemeralMaxTotalBytes) ?? + DEFAULT_EPHEMERAL_MAX_TOTAL_BYTES; + // A handle expiring before its upload URL means uploads succeed and every redemption then // fails with a bare "jwt expired". if (handleTtlSeconds <= uploadUrlTtlSeconds) { @@ -122,13 +127,11 @@ export function resolveFileUploads( keyPrefix: options.keyPrefix ?? DEFAULT_KEY_PREFIX, uploadUrlTtlSeconds, handleTtlSeconds, - maxBytes: positiveInteger('maxBytes', options.maxBytes) ?? DEFAULT_MAX_BYTES, + maxBytes, downloadTimeoutSeconds: positiveInteger('downloadTimeoutSeconds', options.downloadTimeoutSeconds) ?? DEFAULT_DOWNLOAD_TIMEOUT_SECONDS, - ephemeralMaxTotalBytes: - positiveInteger('ephemeralMaxTotalBytes', options.ephemeralMaxTotalBytes) ?? - DEFAULT_EPHEMERAL_MAX_TOTAL_BYTES, + ephemeralMaxTotalBytes, authSecret, limitDownload: createSemaphore( positiveInteger('maxConcurrentDownloads', options.maxConcurrentDownloads) ?? diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 2086d370b7..6c3df6dfaf 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -161,19 +161,14 @@ export interface ForestMCPServerOptions { */ allowedOAuthClients?: string[]; /** - * Enables file fields in action forms through an upload side-channel. Without it, action - * file fields are unusable over MCP: the agent expects them as data uris, which would - * transit the model's context window and exceed most clients' payload limits. - * See the README for the flow and the storage contract. + * Action file uploads are on by default, with the objects held in memory. This only configures + * them — a `storage` backend, size limits, ttls. Drop `requestActionFileUpload` from + * `enabledTools` to turn the feature off entirely. See the README for the flow and the storage + * contract. * * @experimental Expected to change to follow the MCP file transfer specification once it * lands (SEP-2631). */ - /** - * Action file uploads are on by default, with objects held in memory. This only configures - * them — a `storage` backend, size limits, ttls. Drop `requestActionFileUpload` from - * `enabledTools` to turn the feature off entirely. - */ fileUploads?: FileUploadsOptions; } @@ -466,7 +461,9 @@ export default class ForestMCPServer { // On unless the tool was left out of enabledTools, which is the one way to turn it off. Gating // on that keeps executeAction from advertising an upload tool the server never registered. - if (this.enabledTools.has('requestActionFileUpload')) { + // `!this.fileUploads` because an agent rebuilds its router on every customization refresh: + // re-initializing would hand the new app a different store than the urls already in flight. + if (this.enabledTools.has('requestActionFileUpload') && !this.fileUploads) { // No backend given: hold the objects here. Correct for one instance only, which // EphemeralStorage announces the first time something actually asks for a destination — // warning at boot would tax every agent, including those with no file field at all. @@ -482,6 +479,23 @@ export default class ForestMCPServer { authSecret, this.logger, ); + + // Said here rather than in resolveFileUploads, which cannot tell the in-memory store from a + // provided one: the tool reports maxBytes to the model verbatim, so a value above what the + // whole store holds promises a size every upload of which is refused — with "the store is + // full", on an empty store. + if ( + this.ephemeralStorage && + this.fileUploads.maxBytes > this.fileUploads.ephemeralMaxTotalBytes + ) { + this.logger( + 'Warn', + `fileUploads.maxBytes=${this.fileUploads.maxBytes} exceeds what the in-memory store ` + + `holds in total (${this.fileUploads.ephemeralMaxTotalBytes} bytes, ` + + 'ephemeralMaxTotalBytes), so an upload that large is always refused. Raise ' + + 'ephemeralMaxTotalBytes too, or configure a storage backend.', + ); + } } await this.fetchCollectionNames(); diff --git a/packages/mcp-server/src/tools/request-action-file-upload.ts b/packages/mcp-server/src/tools/request-action-file-upload.ts index 6bb2c81685..d84e35cffd 100644 --- a/packages/mcp-server/src/tools/request-action-file-upload.ts +++ b/packages/mcp-server/src/tools/request-action-file-upload.ts @@ -38,7 +38,9 @@ function sanitizeFilename(filename: string): string { const safe = filename .trim() .slice(-MAX_FILENAME_LENGTH) - .replace(/[^\w.\- ()]/g, '_'); + // Unicode-aware: ASCII \w turns `facture-été.pdf` into `facture-_t_.pdf`, so the accented + // letters vanish while the spaces and parentheses survive. Same key safety, no `/`. + .replace(/[^\p{L}\p{N}._\- ()]/gu, '_'); return !safe || /^\.+$/.test(safe) ? 'file' : safe; } diff --git a/packages/mcp-server/src/utils/load-file-uploads.ts b/packages/mcp-server/src/utils/load-file-uploads.ts index 66b331da2f..97507f0db6 100644 --- a/packages/mcp-server/src/utils/load-file-uploads.ts +++ b/packages/mcp-server/src/utils/load-file-uploads.ts @@ -66,8 +66,8 @@ export default async function loadFileUploads( ); } - // An object without a storage is legitimate — it selects the in-memory store. Nothing at all is - // a mistake, most likely a module that forgot to export. + // An object without a storage key is legitimate — it selects the in-memory store. Nothing at all + // is a mistake, most likely a module that forgot to export. if (!options || typeof options !== 'object') { throw new Error( `FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" must export the fileUploads options, or a ` + @@ -75,5 +75,24 @@ export default async function loadFileUploads( ); } + // Whoever sets this variable did it *for* the storage: a bad import or a factory that returned + // early leaves `{ storage: undefined }`, which the server would silently replace with the + // in-memory store — putting a deliberately replicated deployment on the one backend that cannot + // serve it. A missing method fails later, on the first user's upload, so it is checked here too. + if ('storage' in options) { + const storage = options.storage as unknown as Record | undefined; + const missing = ['createUploadUrl', 'download', 'getSize'].filter( + method => typeof storage?.[method] !== 'function', + ); + + if (missing.length) { + throw new Error( + `FOREST_MCP_UPLOAD_STORAGE_MODULE "${modulePath}" exports a storage missing ` + + `${missing.join(', ')}. An UploadStorage implements createUploadUrl, download and ` + + 'getSize. Omit the storage entirely to use the in-memory store on purpose.', + ); + } + } + return options; } diff --git a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts index 396470f876..15bac672e2 100644 --- a/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts +++ b/packages/mcp-server/test/file-uploads/ephemeral-storage.test.ts @@ -85,8 +85,9 @@ describe('EphemeralStorage', () => { await expect(storage.getSize(key)).resolves.toBe(body.length); await expect(storage.download(key)).resolves.toEqual(body); - // Single-use against this backend: keeping consumed objects would fill a small store. - await expect(storage.getSize(key)).resolves.toBeUndefined(); + // Kept, not consumed: executeAction downloads every reference before it runs, so a later + // failure must not leave a retry with handles whose objects are gone. + await expect(storage.download(key)).resolves.toEqual(body); }); it('accepts an empty upload', async () => { diff --git a/packages/mcp-server/test/file-uploads/semaphore.test.ts b/packages/mcp-server/test/file-uploads/semaphore.test.ts index e9ab6f5b92..0f515528e7 100644 --- a/packages/mcp-server/test/file-uploads/semaphore.test.ts +++ b/packages/mcp-server/test/file-uploads/semaphore.test.ts @@ -60,13 +60,6 @@ describe('createSemaphore', () => { ).rejects.toThrow('storage is down'); }); - it.each([[0], [-1]])('treats a limit of %p as 1 rather than hanging forever', async limit => { - const run = createSemaphore(limit); - - await expect(run(async () => 'ran')).resolves.toBe('ran'); - await expect(run(async () => 'ran again')).resolves.toBe('ran again'); - }); - it('never exceeds the limit under load', async () => { let active = 0; let peak = 0; diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 7134c61c59..b652f81d9b 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3650,6 +3650,16 @@ describe('file uploads without a storage backend', () => { ); }); + // The tool reports maxBytes to the model verbatim, so this would promise a size the store always + // refuses — with "the store is full", on an empty store. + it('warns when maxBytes exceeds what the in-memory store can ever hold', async () => { + const logger = jest.fn(); + + await buildApp({ logger, fileUploads: { maxBytes: 100 * 1024 * 1024 } }); + + expect(logger).toHaveBeenCalledWith('Warn', expect.stringContaining('always refused')); + }); + // enabledTools is the off switch, and it has to take the endpoint with it. it('serves no upload endpoint when the tool is not enabled', async () => { const app = await buildApp({ enabledTools: ['describeCollection', 'list'] }); diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index dd89aea8a9..abe95adf0b 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -277,7 +277,10 @@ describe('declareGetActionFormTool', () => { expect(mockTryToSetFields).toHaveBeenCalledWith(values); }); - it('passes an upload handle through verbatim instead of resolving it', async () => { + // Resolving here would put the bytes back in the model's context, and handing the handle to a + // change hook makes it read `.buffer` off a string and throw a 500. So it is withheld, and the + // other values still reach the hook. + it('withholds an upload handle from the change hooks instead of resolving it', async () => { const mockTryToSetFields = jest.fn().mockResolvedValue([]); const mockAction = jest.fn().mockResolvedValue({ getFields: jest.fn().mockReturnValue([]), @@ -288,13 +291,17 @@ describe('declareGetActionFormTool', () => { authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, } as unknown as ReturnType); - const values = { document: '$uploadedFile:some-token' }; + const values = { + document: '$uploadedFile:some-token', + attachments: ['$uploadedFile:another'], + note: 'hello', + }; await registeredToolHandler( { collectionName: 'users', actionName: 'sendEmail', recordIds: [1], values }, mockExtra, ); - expect(mockTryToSetFields).toHaveBeenCalledWith({ document: '$uploadedFile:some-token' }); + expect(mockTryToSetFields).toHaveBeenCalledWith({ note: 'hello' }); }); it('should not call tryToSetFields when values are not provided', async () => { @@ -323,6 +330,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'subject', getType: () => 'String', + getTypeName: () => 'String', getValue: () => undefined, isRequired: () => true, getPlainField: () => ({}), @@ -331,6 +339,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'message', getType: () => 'String', + getTypeName: () => 'String', getValue: () => 'Default message', isRequired: () => false, getPlainField: () => ({}), @@ -374,6 +383,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'subject', getType: () => 'String', + getTypeName: () => 'String', getValue: () => 'Test Subject', isRequired: () => true, getPlainField: () => ({}), @@ -382,6 +392,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'message', getType: () => 'String', + getTypeName: () => 'String', getValue: () => 'Test Message', isRequired: () => true, getPlainField: () => ({}), @@ -416,6 +427,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'subject', getType: () => 'String', + getTypeName: () => 'String', getValue: () => undefined, isRequired: () => true, getPlainField: () => ({}), @@ -424,6 +436,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'message', getType: () => 'String', + getTypeName: () => 'String', getValue: () => 'Test Message', isRequired: () => false, getPlainField: () => ({}), @@ -458,6 +471,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'quantity', getType: () => 'Number', + getTypeName: () => 'Number', getValue: () => 0, isRequired: () => true, getPlainField: () => ({}), @@ -492,6 +506,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'isActive', getType: () => 'Boolean', + getTypeName: () => 'Boolean', getValue: () => false, isRequired: () => true, getPlainField: () => ({}), @@ -526,6 +541,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'notes', getType: () => 'String', + getTypeName: () => 'String', getValue: () => '', isRequired: () => true, getPlainField: () => ({}), @@ -560,6 +576,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'subject', getType: () => 'String', + getTypeName: () => 'String', getValue: () => null, isRequired: () => true, getPlainField: () => ({}), @@ -594,6 +611,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'optionalField', getType: () => 'String', + getTypeName: () => 'String', getValue: () => undefined, isRequired: () => false, getPlainField: () => ({}), @@ -628,6 +646,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'status', getType: () => 'Enum', + getTypeName: () => 'Enum', getValue: () => undefined, isRequired: () => true, getPlainField: () => ({}), @@ -636,6 +655,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'message', getType: () => 'String', + getTypeName: () => 'String', getValue: () => undefined, isRequired: () => false, getPlainField: () => ({}), @@ -682,6 +702,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'plan', getType: () => 'String', + getTypeName: () => 'String', getValue: () => undefined, isRequired: () => true, getPlainField: () => ({ description: 'Subscription plan' }), @@ -695,6 +716,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'priority', getType: () => 'String', + getTypeName: () => 'String', getValue: () => undefined, isRequired: () => false, getPlainField: () => ({}), @@ -779,6 +801,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'subject', getType: () => 'String', + getTypeName: () => 'String', getValue: () => 'Test Subject', isRequired: () => true, getPlainField: () => ({}), @@ -820,6 +843,7 @@ describe('declareGetActionFormTool', () => { { getName: () => 'subject', getType: () => 'String', + getTypeName: () => 'String', getValue: () => undefined, isRequired: () => true, getPlainField: () => ({}), diff --git a/packages/mcp-server/test/utils/load-file-uploads.test.ts b/packages/mcp-server/test/utils/load-file-uploads.test.ts index f539741395..59930c0503 100644 --- a/packages/mcp-server/test/utils/load-file-uploads.test.ts +++ b/packages/mcp-server/test/utils/load-file-uploads.test.ts @@ -86,6 +86,21 @@ describe('loadFileUploads', () => { await expect(loadFileUploads(file)).rejects.toThrow(/failed while loading: nope/); }); + // The variable exists for the storage, so a storage that is present but broken must not fall + // back to the very backend the operator was configuring their way out of. + it.each([ + ['undefined', 'module.exports = { storage: undefined };'], + ['not an object', 'module.exports = { storage: "s3" };'], + [ + 'missing getSize', + 'module.exports = { storage: { createUploadUrl: () => {}, download: () => {} } };', + ], + ])('rejects a storage that is %s instead of falling back to memory', async (_, body) => { + const file = writeModule(body); + + await expect(loadFileUploads(file)).rejects.toThrow(/exports a storage missing/); + }); + // Omitting the storage selects the in-memory store, so it must not be an error. it('accepts options without a storage', async () => { const file = writeModule(`module.exports = { maxBytes: 10 };`); From 49bd1d1fef6a8d9ed34c9d85606ff6dde30f0934 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 17:23:12 +0200 Subject: [PATCH 33/39] docs(mcp-server): say why the store refuses early, and what getSize may reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comments that described the code less precisely than the review of them did: the total-bytes check reads as being about the declared body size when it is about the store having no room at all, and the `getSize` contract said what to return when a size is unavailable but nothing about an absent object — which is the likeliest outcome of the whole flow. --- packages/mcp-server/src/file-uploads/ephemeral-storage.ts | 3 +++ packages/mcp-server/src/file-uploads/types.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts index 805648408b..0d4d821fad 100644 --- a/packages/mcp-server/src/file-uploads/ephemeral-storage.ts +++ b/packages/mcp-server/src/file-uploads/ephemeral-storage.ts @@ -164,6 +164,9 @@ export default class EphemeralStorage implements UploadStorage { // refuse a replacement that fits, and keeping them until 'end' would hold both at once. this.forget(key); + // Refused before a byte is read whenever the store has no room at all, whatever this body + // turns out to be. `>=` rather than `>` so the answer is a 507 naming the store, instead of a + // 413 decided on the first chunk that reads as a problem with the file. if (this.storedBytes + this.inFlightBytes >= maxTotalBytes) { refuse(res, key, 507, `the in-memory store is full (${maxTotalBytes} bytes)`); diff --git a/packages/mcp-server/src/file-uploads/types.ts b/packages/mcp-server/src/file-uploads/types.ts index c328a9db7a..fd09548ad8 100644 --- a/packages/mcp-server/src/file-uploads/types.ts +++ b/packages/mcp-server/src/file-uploads/types.ts @@ -21,6 +21,11 @@ export interface UploadStorage { * Size of the uploaded object, used to reject oversized uploads before downloading them. * Return undefined when the backend cannot report it cheaply — `maxBytes` is then only * enforced after the bytes are in memory, so the process holds the whole object either way. + * + * Rejecting is fine when the object is absent — reading its metadata is how most backends + * answer this, and a missing object is the likeliest outcome of the whole flow, the upload + * having been blocked. The rejection is reported to the caller with the field name and the + * message, so it does not have to be distinguished from an unreadable one here. */ getSize(key: string): Promise; } From f720ae5cf8538e1072bbe2e2913635073ff94bbc Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 13 Aug 2026 17:29:52 +0200 Subject: [PATCH 34/39] test(agent-testing): restore the list type this package always asserted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed to 'StringList' earlier in this PR to match a getType() that has since been reverted, so it went back to failing. `['String']` is what it asserted from the day the package landed — the oldest evidence in the repo that the agent emits the array, and the reason the wire had to stay as it was. --- packages/agent-testing/test/action.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-testing/test/action.test.ts b/packages/agent-testing/test/action.test.ts index 6ab9c277cd..a8cdbec741 100644 --- a/packages/agent-testing/test/action.test.ts +++ b/packages/agent-testing/test/action.test.ts @@ -214,7 +214,7 @@ describe('action', () => { 'Json', 'Number', 'String', - 'StringList', + ['String'], 'Number', 'Enum', 'Number', From cee810930bddeaa68bf3641ecec46ee8b8cdd3ab Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 14 Aug 2026 11:26:01 +0200 Subject: [PATCH 35/39] docs(mcp-server): the Claude Desktop upload is verified, not expected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested from a Claude Desktop chat against an agent behind a public HTTPS URL, with that host added to the sandbox's allowed domains: the model's PUT goes through. The same sandbox had answered `Host not in allowlist` for an ordinary public domain beforehand, so the allowlist is the whole of the second condition and satisfying it is enough. Still no menu path in either the tool description or here. Where the setting lives differs between clients and versions, and naming one sends a blocked user to a panel that may not have it — which is exactly what happened while testing this. --- packages/mcp-server/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 56d8322805..dc268c418b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -270,8 +270,12 @@ be able to make it: Do not promise your users a menu path; give them the host to get allowed. Both are client-side and outside this server's control, so document your upload host for your - users. Condition 1 is established; condition 2 is expected to be sufficient but is **not yet - verified end to end** against a public agent. + users. + + **Verified end to end** from a Claude Desktop chat: an agent behind a public HTTPS URL, that host + added to the sandbox's allowed domains, and the model's `PUT` goes through. Before the host was + allowed, the same sandbox answered `Host not in allowlist` even for an ordinary public domain — + so the allowlist is the whole of condition 2, and satisfying it is enough. The tool states this prerequisite in its description and repeats it in its response, so a model whose upload was blocked has the diagnosis in context. From 2190fd547d81ec584bea2e5a6195461d8a5996fe Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 14 Aug 2026 15:06:54 +0200 Subject: [PATCH 36/39] fix(mcp-server): keep a required file field satisfiable, and add a way off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withholding the handle from the change hooks fixed a 500 but made the field unfillable: the agent never saw it, so `getValue()` stayed undefined, the field stayed in `requiredFields`, and `canExecute` could never become true. The tool's own description tells the model to call `getActionForm` until it does — with no value that gets there, since a data uri is what it is told never to send and the handle was being dropped. The handle is now echoed back as that field's value and counted as filling it, while still never reaching the agent. `fileUploads: false` turns the whole feature off. `enabledTools` could do it, but it is an allowlist: declining one experimental feature that way meant naming the ten other tools and opting out of everything shipped later — a poor trade for something on by default. It drops the tool from the enabled set, so registration, the upload endpoint and the executeAction paragraph all follow. Also states the store's real capacity: an object survives redemption now, so 64 MiB of defaults holds about three max-size files per 45-minute window rather than a rolling 64 MiB. --- packages/agent/src/agent.ts | 8 ++-- packages/mcp-server/README.md | 11 ++++-- packages/mcp-server/src/server.ts | 18 ++++++--- .../mcp-server/src/tools/get-action-form.ts | 37 +++++++++++++----- packages/mcp-server/test/server.test.ts | 16 ++++++++ .../test/tools/get-action-form.test.ts | 39 +++++++++++++++++++ 6 files changed, 107 insertions(+), 22 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 4f0cb89bfe..f0f9070ac7 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -57,7 +57,7 @@ export default class Agent extends FrameworkMounter private mcpBasePath?: string; private mcpTokenTtl?: TokenTtlOptions; private mcpAllowedOAuthClients?: string[]; - private mcpFileUploads?: FileUploadsOptions; + private mcpFileUploads?: false | FileUploadsOptions; /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; @@ -266,15 +266,17 @@ export default class Agent extends FrameworkMounter * // registered redirect URIs (subdomains included). Other clients get invalid_client. * agent.mountAiMcpServer({ allowedOAuthClients: ['dust.tt'] }); * // Example: action File fields work over MCP out of the box, with the files held in memory. - * // Experimental. Point them at a real backend when one instance is not enough. + * // Experimental. Point them at a real backend when one instance is not enough, or pass + * // false to turn the feature off. * agent.mountAiMcpServer({ fileUploads: { storage } }); + * agent.mountAiMcpServer({ fileUploads: false }); */ mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string; tokenTtl?: TokenTtlOptions; allowedOAuthClients?: string[]; - fileUploads?: FileUploadsOptions; + fileUploads?: false | FileUploadsOptions; }): this { this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index dc268c418b..2b559f776b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -203,8 +203,10 @@ agent.mountAiMcpServer(); // in memory, single insta agent.mountAiMcpServer({ fileUploads: { storage } }); // a real backend ``` -To turn the feature off, leave `requestActionFileUpload` out of `enabledTools`. The upload endpoint -is then never mounted and `executeAction` never mentions it. +To turn the feature off, pass `fileUploads: false`. The tool is not registered, the upload endpoint +is never mounted, and `executeAction` stops mentioning either. Going through `enabledTools` would +work too, but it is an allowlist — declining this one feature that way means naming every other +tool and opting out of everything shipped after. > **Single instance only.** The upload and the redemption are two separate requests. With several > replicas, in cluster mode, or on a serverless runtime, one of them lands @@ -213,7 +215,10 @@ is then never mounted and `executeAction` never mentions it. > warning at startup, and the failure names this cause. **Those deployments need a `storage`.** `ephemeralMaxTotalBytes` bounds what the in-memory store holds across all pending uploads, 64 MiB by -default. It is deliberately absolute rather than a multiple of `maxBytes`: derived, raising the +default. Redeeming a file does not free it — the object lives until `handleTtlSeconds` so a retry +after a failed action still finds it — so on the defaults the store holds about three max-size +files per 45-minute window rather than a rolling 64 MiB. Size it against that, or shorten +`handleTtlSeconds`. It is deliberately absolute rather than a multiple of `maxBytes`: derived, raising the per-file limit would multiply what the process can hold. ### With a storage backend diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 6c3df6dfaf..c45734a940 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -161,15 +161,15 @@ export interface ForestMCPServerOptions { */ allowedOAuthClients?: string[]; /** - * Action file uploads are on by default, with the objects held in memory. This only configures - * them — a `storage` backend, size limits, ttls. Drop `requestActionFileUpload` from - * `enabledTools` to turn the feature off entirely. See the README for the flow and the storage - * contract. + * Action file uploads are on by default, with the objects held in memory. Pass an object to + * configure them — a `storage` backend, size limits, ttls — or `false` to turn the feature off: + * no `requestActionFileUpload` tool, no upload endpoint, and `executeAction` stops mentioning + * either. See the README for the flow and the storage contract. * * @experimental Expected to change to follow the MCP file transfer specification once it * lands (SEP-2631). */ - fileUploads?: FileUploadsOptions; + fileUploads?: false | FileUploadsOptions; } /** @@ -214,7 +214,13 @@ export default class ForestMCPServer { this.allowedOAuthClients = normalizeDomainList(options?.allowedOAuthClients); // Resolved in buildExpressApp, where the auth secret is known to be set. - this.fileUploadsOptions = options?.fileUploads; + this.fileUploadsOptions = options?.fileUploads || undefined; + + // `enabledTools` is an allowlist, so declining this one feature through it would mean naming + // the ten other tools and opting out of everything shipped later. Dropping it from the set + // here turns the whole feature off through the machinery that already gates it: registration, + // the upload endpoint, and the paragraph executeAction adds for it. + if (options?.fileUploads === false) this.enabledTools.delete('requestActionFileUpload'); // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index 71cc611667..7cf1641971 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -27,16 +27,23 @@ function toAllowedValue(option: unknown): { value: string | number | null; label // Setting a field that declares a change hook posts every field value to the agent, and a change // hook reading `.buffer` off a handle string throws — a 500 on the very sequence executeAction's // description prescribes. Handles are not resolved on this path on purpose (the bytes would land -// back in the model's context), so they are withheld from the hook instead: the field reads as -// unset, which is what it was before the model chose a destination for it. -function withoutFileReferences(values: Record): Record { - const kept = Object.entries(values).filter(([, value]) => { +// back in the model's context), so they are kept away from the agent and echoed back to the model +// instead: a required file field stays satisfiable, and the model sees its handle was received. +function splitFileReferences(values: Record): { + forAgent: Record; + withheld: Record; +} { + const forAgent: Record = {}; + const withheld: Record = {}; + + for (const [field, value] of Object.entries(values)) { const candidates = Array.isArray(value) ? value : [value]; - return !candidates.some(candidate => parseFileReference(candidate)); - }); + if (candidates.some(candidate => parseFileReference(candidate))) withheld[field] = value; + else forAgent[field] = value; + } - return Object.fromEntries(kept); + return { forAgent, withheld }; } export default function declareGetActionFormTool(mcpServer: McpServer, ctx: ToolContext): string { @@ -80,16 +87,26 @@ The response includes: .action(options.actionName, { recordIds }); let skippedFields: string[] = []; + let withheld: Record = {}; if (options.values) { - skippedFields = await action.tryToSetFields(withoutFileReferences(options.values)); + const split = splitFileReferences(options.values); + + withheld = split.withheld; + skippedFields = await action.tryToSetFields(split.forAgent); } const fields = action.getFields(); + // A withheld handle satisfies its field: the agent never saw it, so getValue() is undefined, + // and counting it as missing would leave canExecute false with nothing the model could send + // to fix it — a data uri is what it is told never to send, and the handle is what it just + // sent. + const valueOf = (field: { getName(): string; getValue(): unknown }) => + field.getName() in withheld ? withheld[field.getName()] : field.getValue(); const requiredFields = fields .filter(field => field.isRequired()) - .filter(field => field.getValue() === undefined || field.getValue() === null) + .filter(field => valueOf(field) === undefined || valueOf(field) === null) .map(field => field.getName()); const canExecute = requiredFields.length === 0; @@ -104,7 +121,7 @@ The response includes: const baseField = { name: field.getName(), type: field.getTypeName(), - value: field.getValue(), + value: valueOf(field), isRequired: field.isRequired() ?? false, ...(description ? { description } : {}), }; diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index b652f81d9b..14b3a7afbe 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3669,6 +3669,22 @@ describe('file uploads without a storage backend', () => { expect(response.status).toBe(405); }); + // enabledTools is an allowlist, so declining this one feature through it would mean naming every + // other tool and opting out of everything shipped later. + it('turns the whole feature off on fileUploads: false, without touching enabledTools', async () => { + const server = build({ fileUploads: false }); + const app = await server.buildExpressApp(new URL('https://agent.example')); + + await expect(request(app).put('/mcp/uploads/anything').send('hello')).resolves.toMatchObject({ + status: 405, + }); + expect((server as unknown as { enabledTools: Set }).enabledTools).not.toContain( + 'requestActionFileUpload', + ); + // Every other tool is untouched, which is the point of not going through enabledTools. + expect((server as unknown as { enabledTools: Set }).enabledTools).toContain('list'); + }); + // 404 comes from the uploads router itself, for a key it never handed out. A 405 would mean // allowedMethods(['POST']) claimed the PUT first, and a hang would mean a body parser did. it('serves the upload endpoint under /mcp/uploads', async () => { diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index abe95adf0b..74dc7e9b58 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -304,6 +304,45 @@ describe('declareGetActionFormTool', () => { expect(mockTryToSetFields).toHaveBeenCalledWith({ note: 'hello' }); }); + // Withholding must not make the field unsatisfiable: the agent never sees the handle, so + // counting it as missing would leave canExecute false with nothing the model could send. + it('counts a withheld handle as filling its required field, and echoes it back', async () => { + const mockTryToSetFields = jest.fn().mockResolvedValue([]); + const mockAction = jest.fn().mockResolvedValue({ + getFields: jest.fn().mockReturnValue([ + { + getName: () => 'Document', + getType: () => 'File', + getTypeName: () => 'File', + getValue: () => undefined, + isRequired: () => true, + getPlainField: () => ({}), + getMultipleChoiceField: () => ({ getOptions: () => null }), + }, + ]), + tryToSetFields: mockTryToSetFields, + }); + mockBuildClientWithActions.mockResolvedValue({ + rpcClient: { collection: jest.fn().mockReturnValue({ action: mockAction }) }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { + collectionName: 'users', + actionName: 'sendEmail', + recordIds: [1], + values: { Document: '$uploadedFile:some-token' }, + }, + mockExtra, + ); + const payload = JSON.parse((result as { content: { text: string }[] }).content[0].text); + + expect(payload.canExecute).toBe(true); + expect(payload.requiredFields).toEqual([]); + expect(payload.fields[0].value).toBe('$uploadedFile:some-token'); + }); + it('should not call tryToSetFields when values are not provided', async () => { const mockGetFields = jest.fn().mockReturnValue([]); const mockTryToSetFields = jest.fn().mockResolvedValue([]); From b6f30bea4c2eeb728387ce49780e5d9f48b23594 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 14 Aug 2026 15:30:18 +0200 Subject: [PATCH 37/39] docs(mcp-server): make the sha256 pin the stated default, not an aside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threat-modeling the leaked-url case per backend showed the two defenses do not live where they seemed to. The in-memory store is single-use, so a leaked url cannot replace bytes that already landed. A presigned backend url is the opposite: S3 accepts as many PUTs as fit in expiresInSeconds, so on the production backend a url leaked to an access log can overwrite the upload after it happened, until the action runs. The sha256 pin is the one defense that covers both — S3 signs it into the url so a different payload is rejected at upload, and redemption re-verifies it regardless. Requiring the signed handle on the PUT instead would not even be implementable there: a presigned request carries its signature in the query, and S3 rejects a request presenting a second authorization mechanism. So the tool now tells the model to compute and pass the digest as the normal course, skipping it only when it cannot, and the README states the replayable window an unpinned upload accepts. --- packages/mcp-server/README.md | 11 +++++++++-- .../src/tools/request-action-file-upload.ts | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 2b559f776b..6c599d7fbf 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -189,8 +189,15 @@ itself is the authorization, as with an S3 presigned PUT. It carries a random uu before a byte is read unless this server issued it, expires with `uploadUrlTtlSeconds`, and serves nothing but the `PUT`. Against the in-memory store it also **accepts a single upload**: once the bytes land, a leaked URL can no longer replace them. Writing is not consuming either — redemption -needs the signed handle, which is bound to the user who requested it. Pin `sha256` when the exact -content matters; it is re-verified after download. +needs the signed handle, which is bound to the user who requested it. + +A presigned backend URL is a different animal: it is typically **replayable** until it expires — S3 +accepts as many `PUT`s as fit in `expiresInSeconds` — so there, a URL leaked to an access log can +overwrite the bytes *after* the legitimate upload and before the action runs. The `sha256` pin is +the defense that covers every backend at once: S3 signs it into the URL, so a different payload is +rejected at upload time, and redemption re-verifies the digest regardless of what the backend +checked. The tool instructs the model to pin by default; treat an unpinned upload as accepting that +window. ### Nothing to provision, and nothing to switch on diff --git a/packages/mcp-server/src/tools/request-action-file-upload.ts b/packages/mcp-server/src/tools/request-action-file-upload.ts index d84e35cffd..9b7d4bc536 100644 --- a/packages/mcp-server/src/tools/request-action-file-upload.ts +++ b/packages/mcp-server/src/tools/request-action-file-upload.ts @@ -69,7 +69,7 @@ export default function declareRequestActionFileUploadTool( Call this whenever getActionForm shows a field of type "File" or "FileList". Never inline base64 file content as a field value: it would be far larger than any payload limit. Workflow: -1. Call this tool with the filename and mimeType. Pass sha256 to pin the upload to that exact content. +1. Compute the file's sha256 and call this tool with the filename, mimeType and sha256. The digest pins the destination to that exact content — without it, anyone who reads the upload url in a log can replace the bytes before the action runs. Skip it only when you cannot compute a digest. 2. Upload the raw bytes to the returned uploadUrl, with the returned method and every returned header. Some backends sign a pinned sha256 into a checksum header and reject the upload without it, so apply the headers as returned rather than assuming which ones matter. A pinned digest is re-verified when the action runs either way. The bytes must not pass through this tool or through your own output. 3. Pass the returned fileHandle string as the value of the file field in executeAction. @@ -86,7 +86,10 @@ The url accepts one upload: to send different bytes, call this tool again for a sha256: z .string() .optional() - .describe('Optional sha256 digest of the file, hex or base64, to pin the upload.'), + .describe( + 'Sha256 digest of the file, hex or base64. Pins the destination to that exact ' + + 'content; compute it and pass it whenever you can.', + ), }, }, async (options: RequestActionFileUploadArgument, extra) => { From cb63a044b88a455723db9317ef40512e3b5dc3d9 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 14 Aug 2026 15:45:46 +0200 Subject: [PATCH 38/39] fix(mcp-server): review the delta the reviews had not covered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three agents over the last unreviewed 121 lines. What they caught: - `in` on the withheld map walked the prototype chain, so a required field literally named toString read as filled by Object.prototype.toString and canExecute came back true on an empty form. Own-property check instead. - fileUploads: false silently deleted a tool the caller had explicitly listed in enabledTools — in a resolver whose convention is to log every enablement surprise. It still wins, and now says so. - The standalone server had no way to express fileUploads: false, while the README discouraged the one route it had. FOREST_MCP_FILE_UPLOADS returns with the only meaning left to it: 'false' turns the feature off, wins over a configured storage module, and any other value than true/false fails at startup instead of silently leaving the feature on. - Two comments this PR itself falsified still named enabledTools as "the one way" to turn uploads off (server.ts and CLAUDE.md); the tool description claimed url-leak substitution unconditionally when the in-memory store's single-use url makes it impossible after the upload; the README said the single-instance warning fires at startup when it fires on first use; and the S3 example never said unhoistableHeaders is what keeps the checksum enforced. New tests: the FileList half of the withheld-handle behavior (array echoed back, required field counted as filled), the prototype-chain case, and the fileUploads-false-vs-enabledTools precedence with its warning. --- packages/mcp-server/CLAUDE.md | 2 +- packages/mcp-server/README.md | 18 +++-- packages/mcp-server/src/cli.ts | 23 +++++- packages/mcp-server/src/server.ts | 25 +++++-- .../mcp-server/src/tools/get-action-form.ts | 7 +- .../src/tools/request-action-file-upload.ts | 2 +- packages/mcp-server/test/server.test.ts | 22 +++++- .../test/tools/get-action-form.test.ts | 74 +++++++++++++++++++ 8 files changed, 152 insertions(+), 21 deletions(-) diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 7669698916..4358f33e30 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -16,7 +16,7 @@ Key flows that only make sense across files: - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). -- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), on by default: `EphemeralStorage` holds the objects in memory unless `fileUploads.storage` provides a backend. `enabledTools` is the only off switch — leaving `requestActionFileUpload` out skips the tool, the upload endpoint and the `executeAction` instructions together. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. `FieldGetter.getType()` returns the wire value (`['File']`) because agent-bff and workflow-executor put it straight into API responses; `getTypeName()` is the collapsed `'FileList'`, for dispatch and for what this server reports to a model. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context — and withholds them from `tryToSetFields` so a change hook fired by another field never reads `.buffer` off a handle string. `download` is **not** consume-on-read: `resolve.ts` fetches every reference before `setFields` and `execute`, so a later failure must leave the objects retryable. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns on the first `createUploadUrl` rather than at startup — uploads being on by default, a boot warning would reach agents that have no file field at all. +- **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), on by default: `EphemeralStorage` holds the objects in memory unless `fileUploads.storage` provides a backend. `fileUploads: false` is the off switch (it drops `requestActionFileUpload` from the enabled set, so the tool, the upload endpoint and the `executeAction` instructions all follow); leaving the tool out of `enabledTools` does the same, at the cost of freezing the allowlist. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. `FieldGetter.getType()` returns the wire value (`['File']`) because agent-bff and workflow-executor put it straight into API responses; `getTypeName()` is the collapsed `'FileList'`, for dispatch and for what this server reports to a model. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context — and withholds them from `tryToSetFields` so a change hook fired by another field never reads `.buffer` off a handle string, while still echoing them back as the field's value and counting them as filling a required field, or `canExecute` could never become true. `download` is **not** consume-on-read: `resolve.ts` fetches every reference before `setFields` and `execute`, so a later failure must leave the objects retryable. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns on the first `createUploadUrl` rather than at startup — uploads being on by default, a boot warning would reach agents that have no file field at all. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. ## Commands diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 6c599d7fbf..0fa0fc156b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -68,7 +68,8 @@ yarn start:dev # Development (loads .env file automatically) | `FOREST_AGENT_URL` | No | your environment's back-end URL | URL the MCP server uses to reach the back-end's data layer. Set it when the server runs next to a self-hosted back-end at an internal address (e.g. `http://localhost:3310`), instead of the public URL registered in Forest | | `FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS` | No | `3600` (1 hour) | Maximum lifetime of the OAuth access tokens the server issues (`tokenTtl.accessTokenSeconds`). Minimum `60` | | `FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS` | No | unbounded | Maximum time between two interactive logins (`tokenTtl.refreshTokenSeconds`). Unset, a client that keeps refreshing never signs in again. Minimum `60` | -| `FOREST_MCP_UPLOAD_STORAGE_MODULE` | No | - | Path to a module providing the `fileUploads` options, for a real storage backend | +| `FOREST_MCP_FILE_UPLOADS` | No | - | `false` turns action file uploads off (they are **on** by default, in memory). Any other value than `true`/`false` fails at startup | +| `FOREST_MCP_UPLOAD_STORAGE_MODULE` | No | - | Path to a module providing the `fileUploads` options, for a real storage backend. `FOREST_MCP_FILE_UPLOADS=false` wins over it | #### Example Configuration @@ -210,16 +211,17 @@ agent.mountAiMcpServer(); // in memory, single insta agent.mountAiMcpServer({ fileUploads: { storage } }); // a real backend ``` -To turn the feature off, pass `fileUploads: false`. The tool is not registered, the upload endpoint -is never mounted, and `executeAction` stops mentioning either. Going through `enabledTools` would -work too, but it is an allowlist — declining this one feature that way means naming every other -tool and opting out of everything shipped after. +To turn the feature off, pass `fileUploads: false` — on the standalone server, +`FOREST_MCP_FILE_UPLOADS=false`. The tool is not registered, the upload endpoint is never mounted, +and `executeAction` stops mentioning either. Going through `enabledTools` would work too, but it is +an allowlist — declining this one feature that way means naming every other tool and opting out of +everything shipped after. > **Single instance only.** The upload and the redemption are two separate requests. With several > replicas, in cluster mode, or on a serverless runtime, one of them lands > on an instance that never saw the other and the action fails — intermittently, which reads as a -> flaky feature rather than a misconfiguration. Objects are also lost on restart. The server logs a -> warning at startup, and the failure names this cause. **Those deployments need a `storage`.** +> flaky feature rather than a misconfiguration. Objects are also lost on restart. The server warns +> the first time an upload destination is asked for, and the failure names this cause. **Those deployments need a `storage`.** `ephemeralMaxTotalBytes` bounds what the in-memory store holds across all pending uploads, 64 MiB by default. Redeeming a file does not free it — the object lives until `handleTtlSeconds` so a retry @@ -347,6 +349,8 @@ const storage: UploadStorage = { }); const url = await getSignedUrl(s3, command, { expiresIn: expiresInSeconds, + // Load-bearing: without it the checksum is hoisted to the query string, which S3 does not + // enforce — the pin would silently stop protecting the upload. ...(sha256 && { unhoistableHeaders: new Set(['x-amz-checksum-sha256']) }), }); return { diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 29a56fd313..67eb6fa05c 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -8,9 +8,25 @@ import parseToolList from './utils/parse-tool-list'; const toSeconds = (value?: string) => (value === undefined ? undefined : Number(value)); async function main() { + // Uploads are on by default, so this variable only means one thing: 'false' turns them off. + // Anything else fails at startup like every other option — 'FALSE' or '0' silently leaving the + // feature on is the kind of surprise an operator meets in production. + const rawFileUploadsFlag = process.env.FOREST_MCP_FILE_UPLOADS; + + if (rawFileUploadsFlag !== undefined && !['true', 'false'].includes(rawFileUploadsFlag)) { + throw new Error( + `Invalid FOREST_MCP_FILE_UPLOADS "${rawFileUploadsFlag}": use 'false' to turn action file ` + + 'uploads off. They are on by default.', + ); + } + // Loaded before constructing, so a bad module fails at startup like every other option. Uploads - // are on without it, held in memory; this only points them at a real backend. - const fileUploads = await loadFileUploads(process.env.FOREST_MCP_UPLOAD_STORAGE_MODULE); + // are on without it, held in memory; this only points them at a real backend. 'false' wins over + // a configured module — an operator setting both is turning the feature off. + const fileUploads = + rawFileUploadsFlag === 'false' + ? (false as const) + : await loadFileUploads(process.env.FOREST_MCP_UPLOAD_STORAGE_MODULE); const server = new ForestMCPServer({ forestServerUrl: process.env.FOREST_SERVER_URL || 'https://api.forestadmin.com', @@ -25,7 +41,8 @@ async function main() { accessTokenSeconds: toSeconds(process.env.FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS), refreshTokenSeconds: toSeconds(process.env.FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS), }, - ...(fileUploads && { fileUploads }), + // Not a truthy spread: `false` is a meaningful value and has to reach the constructor. + ...(fileUploads !== undefined && { fileUploads }), }); await server.run(); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index c45734a940..d4c7bf9455 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -217,10 +217,22 @@ export default class ForestMCPServer { this.fileUploadsOptions = options?.fileUploads || undefined; // `enabledTools` is an allowlist, so declining this one feature through it would mean naming - // the ten other tools and opting out of everything shipped later. Dropping it from the set - // here turns the whole feature off through the machinery that already gates it: registration, - // the upload endpoint, and the paragraph executeAction adds for it. - if (options?.fileUploads === false) this.enabledTools.delete('requestActionFileUpload'); + // every other tool and opting out of everything shipped later. Dropping it from the set here + // turns the whole feature off through the machinery that already gates it: registration, the + // upload endpoint, and the paragraph executeAction adds for it. + if (options?.fileUploads === false) { + // Said aloud when the two options contradict each other: resolveEnabledTools logs every + // other enablement surprise, and a tool the caller named vanishing without a line in the + // log reads as a bug in whichever config layer loses. + if (options.enabledTools?.includes('requestActionFileUpload')) { + this.logger( + 'Warn', + 'fileUploads: false removes requestActionFileUpload even though enabledTools lists it.', + ); + } + + this.enabledTools.delete('requestActionFileUpload'); + } // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -465,8 +477,9 @@ export default class ForestMCPServer { async buildExpressApp(baseUrl?: URL): Promise { const { envSecret, authSecret } = this.ensureSecretsAreSet(); - // On unless the tool was left out of enabledTools, which is the one way to turn it off. Gating - // on that keeps executeAction from advertising an upload tool the server never registered. + // On unless requestActionFileUpload is absent from enabledTools — dropped by the caller, or by + // the fileUploads: false branch in the constructor, which lands in the same set. Gating on + // that keeps executeAction from advertising an upload tool the server never registered. // `!this.fileUploads` because an agent rebuilds its router on every customization refresh: // re-initializing would hand the new app a different store than the urls already in flight. if (this.enabledTools.has('requestActionFileUpload') && !this.fileUploads) { diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index 7cf1641971..f47c269ee8 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -101,8 +101,13 @@ The response includes: // and counting it as missing would leave canExecute false with nothing the model could send // to fix it — a data uri is what it is told never to send, and the handle is what it just // sent. + // An own-property check, not `in`: withheld is keyed by model-sent field names, and `in` + // walks the prototype chain — a field literally named toString would read as filled by a + // function. hasOwnProperty.call because this package's lib predates Object.hasOwn. const valueOf = (field: { getName(): string; getValue(): unknown }) => - field.getName() in withheld ? withheld[field.getName()] : field.getValue(); + Object.prototype.hasOwnProperty.call(withheld, field.getName()) + ? withheld[field.getName()] + : field.getValue(); const requiredFields = fields .filter(field => field.isRequired()) diff --git a/packages/mcp-server/src/tools/request-action-file-upload.ts b/packages/mcp-server/src/tools/request-action-file-upload.ts index 9b7d4bc536..d249fdb7de 100644 --- a/packages/mcp-server/src/tools/request-action-file-upload.ts +++ b/packages/mcp-server/src/tools/request-action-file-upload.ts @@ -69,7 +69,7 @@ export default function declareRequestActionFileUploadTool( Call this whenever getActionForm shows a field of type "File" or "FileList". Never inline base64 file content as a field value: it would be far larger than any payload limit. Workflow: -1. Compute the file's sha256 and call this tool with the filename, mimeType and sha256. The digest pins the destination to that exact content — without it, anyone who reads the upload url in a log can replace the bytes before the action runs. Skip it only when you cannot compute a digest. +1. Compute the file's sha256 and call this tool with the filename, mimeType and sha256. The digest pins the destination to that exact content — without it, on some backends anyone who reads the upload url in a log can replace the bytes before the action runs. Skip it only when you cannot compute a digest. 2. Upload the raw bytes to the returned uploadUrl, with the returned method and every returned header. Some backends sign a pinned sha256 into a checksum header and reject the upload without it, so apply the headers as returned rather than assuming which ones matter. A pinned digest is re-verified when the action runs either way. The bytes must not pass through this tool or through your own output. 3. Pass the returned fileHandle string as the value of the file field in executeAction. diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 14b3a7afbe..60e3e36a59 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3669,8 +3669,6 @@ describe('file uploads without a storage backend', () => { expect(response.status).toBe(405); }); - // enabledTools is an allowlist, so declining this one feature through it would mean naming every - // other tool and opting out of everything shipped later. it('turns the whole feature off on fileUploads: false, without touching enabledTools', async () => { const server = build({ fileUploads: false }); const app = await server.buildExpressApp(new URL('https://agent.example')); @@ -3685,6 +3683,26 @@ describe('file uploads without a storage backend', () => { expect((server as unknown as { enabledTools: Set }).enabledTools).toContain('list'); }); + // Config often merges from two layers; when they contradict each other, false wins — and says so, + // because a tool the caller named vanishing without a log line reads as a bug in the losing layer. + it('lets fileUploads: false override an enabledTools that lists the tool, and warns', async () => { + const logger = jest.fn(); + const server = build({ + logger, + fileUploads: false, + enabledTools: ['describeCollection', 'list', 'requestActionFileUpload'], + }); + await server.buildExpressApp(new URL('https://agent.example')); + + expect((server as unknown as { enabledTools: Set }).enabledTools).not.toContain( + 'requestActionFileUpload', + ); + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('even though enabledTools lists it'), + ); + }); + // 404 comes from the uploads router itself, for a key it never handed out. A 405 would mean // allowedMethods(['POST']) claimed the PUT first, and a hang would mean a body parser did. it('serves the upload endpoint under /mcp/uploads', async () => { diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index 74dc7e9b58..9102810b26 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -343,6 +343,80 @@ describe('declareGetActionFormTool', () => { expect(payload.fields[0].value).toBe('$uploadedFile:some-token'); }); + it('does the same for a FileList: the array of handles fills the field and is echoed back', async () => { + const mockTryToSetFields = jest.fn().mockResolvedValue([]); + const mockAction = jest.fn().mockResolvedValue({ + getFields: jest.fn().mockReturnValue([ + { + getName: () => 'Attachments', + getType: () => ['File'], + getTypeName: () => 'FileList', + getValue: () => undefined, + isRequired: () => true, + getPlainField: () => ({}), + getMultipleChoiceField: () => ({ getOptions: () => null }), + }, + ]), + tryToSetFields: mockTryToSetFields, + }); + mockBuildClientWithActions.mockResolvedValue({ + rpcClient: { collection: jest.fn().mockReturnValue({ action: mockAction }) }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { + collectionName: 'users', + actionName: 'sendEmail', + recordIds: [1], + values: { Attachments: ['$uploadedFile:a', '$uploadedFile:b'] }, + }, + mockExtra, + ); + const payload = JSON.parse((result as { content: { text: string }[] }).content[0].text); + + expect(payload.canExecute).toBe(true); + expect(payload.fields[0].value).toEqual(['$uploadedFile:a', '$uploadedFile:b']); + }); + + // `in` would walk the prototype chain: a field literally named toString would read as filled + // by Object.prototype.toString — a function — and canExecute would come back true on an empty + // form. + it('does not count a field named like an Object.prototype member as filled', async () => { + const mockAction = jest.fn().mockResolvedValue({ + getFields: jest.fn().mockReturnValue([ + { + getName: () => 'toString', + getType: () => 'String', + getTypeName: () => 'String', + getValue: () => undefined, + isRequired: () => true, + getPlainField: () => ({}), + getMultipleChoiceField: () => ({ getOptions: () => null }), + }, + ]), + tryToSetFields: jest.fn().mockResolvedValue([]), + }); + mockBuildClientWithActions.mockResolvedValue({ + rpcClient: { collection: jest.fn().mockReturnValue({ action: mockAction }) }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { + collectionName: 'users', + actionName: 'sendEmail', + recordIds: [1], + values: { note: 'hello' }, + }, + mockExtra, + ); + const payload = JSON.parse((result as { content: { text: string }[] }).content[0].text); + + expect(payload.canExecute).toBe(false); + expect(payload.requiredFields).toEqual(['toString']); + }); + it('should not call tryToSetFields when values are not provided', async () => { const mockGetFields = jest.fn().mockReturnValue([]); const mockTryToSetFields = jest.fn().mockResolvedValue([]); From d6913ab9ac1730b250a3196d362a4a5fc971e97c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 17 Aug 2026 14:52:53 +0200 Subject: [PATCH 39/39] docs(mcp-server): cowork verified too, and the filename is a label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cowork's "does not work" verdict was an artifact of testing against a localhost agent — condition 1 of this very section. Against a public agent with the host allowed it completes end to end, from a single natural sentence, with the sha256 pinned unprompted and no base64 in any tool argument, verified against the request bodies at the tunnel rather than the transcript. Also observed there: a sandbox may normalize the attachment's filename before the model ever sees it, so the name a customer's action stores can differ from the name the user recognises. Stated as: a label, not an identifier. --- packages/mcp-server/README.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 0fa0fc156b..437293e87d 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -271,10 +271,10 @@ be able to make it: - **Claude Code** and custom agents: works. The shell runs on the same machine as the developer, so it reaches a `localhost` agent too — this is the one place the whole flow can be tried end to end against a local agent. Verified. -- **Claude Desktop and Claude.ai**: the attached file lands in the code execution sandbox and the - model can `curl -X PUT -T ` — applying every header the tool returned, since a - pinned `sha256` is signed into `x-amz-checksum-sha256` on S3 and the PUT is rejected without it. - Two conditions, and both are needed: +- **Claude Desktop, Claude.ai and Cowork**: the attached file lands in the code execution sandbox + and the model can `curl -X PUT -T ` — applying every header the tool returned, + since a pinned `sha256` is signed into `x-amz-checksum-sha256` on S3 and the PUT is rejected + without it. Two conditions, and both are needed: 1. **`uploadUrl` must be publicly reachable.** That sandbox is hosted and runs on its own network, so a `localhost` or private address is never reachable from it, whatever else is configured. An agent running on a developer's machine cannot be tested this way. @@ -286,10 +286,18 @@ be able to make it: Both are client-side and outside this server's control, so document your upload host for your users. - **Verified end to end** from a Claude Desktop chat: an agent behind a public HTTPS URL, that host - added to the sandbox's allowed domains, and the model's `PUT` goes through. Before the host was - allowed, the same sandbox answered `Host not in allowlist` even for an ordinary public domain — - so the allowlist is the whole of condition 2, and satisfying it is enough. + **Verified end to end** from a Claude Desktop chat and from a Cowork cloud session: an agent + behind a public HTTPS URL, that host added to the sandbox's allowed domains, and the model's + `PUT` goes through. Before the host was allowed, the same sandbox answered `Host not in + allowlist` even for an ordinary public domain — so the allowlist is the whole of condition 2, + and satisfying it is enough. The Cowork run started from a single natural sentence, with no tool + named: the model found the form, requested a destination and pinned the `sha256` unprompted, and + no tool argument carried base64 — checked against the request bodies at the tunnel, not the + transcript. + + One wrinkle observed there: the filename the action stores is whatever the client reports, and a + sandbox may normalize it (`rapport-1815.pdf` arrived as `rapport1815.pdf` while the bytes and + mime type were exact). Treat it as a label, not an identifier. The tool states this prerequisite in its description and repeats it in its response, so a model whose upload was blocked has the diagnosis in context.