Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
dc93471
feat(datasource-toolkit): expose a shared data uri codec
Aug 10, 2026
5ace7c8
feat(agent-client): encode file values for action File fields
Aug 10, 2026
ebc69ac
refactor: reuse the shared data uri codec
Aug 10, 2026
d0e8682
feat(mcp-server): enable action file fields via an upload side-channel
Aug 10, 2026
4c585f3
fix: address the review of the file upload side-channel
Aug 10, 2026
8b6a0c6
fix: address the automated review findings
Aug 10, 2026
2c57469
refactor(mcp-server): expose the upload destination as a tool
Aug 12, 2026
d1b2bbc
test(mcp-server): cover the file upload configuration and destination
Aug 12, 2026
97660d5
fix: make a bad file value diagnosable by the caller
Aug 12, 2026
d7ca657
docs(_example): demonstrate action file uploads end to end
Aug 12, 2026
f34cfca
fix(_example): contain the local upload storage to its root
Aug 12, 2026
e12cdc5
fix(mcp-server): restore the scope check the route enforced, and name…
Aug 12, 2026
8c68ab4
fix(_example): cap the body the local upload storage accepts
Aug 12, 2026
bed0bbc
fix(datasource-toolkit): reject a malformed data uri readably
Aug 12, 2026
49d688a
fix(mcp-server): bound a storage read and the queue behind it
Aug 12, 2026
d46434f
feat(mcp-server): let the standalone server enable file uploads
Aug 12, 2026
914548c
fix: stop rejecting work the limits were never meant to reject
Aug 12, 2026
4242b06
feat(mcp-server): hold uploads in memory when no storage is configured
Aug 12, 2026
1810811
fix(mcp-server): harden the in-memory upload store against concurrenc…
Aug 12, 2026
5094c9b
fix(mcp-server): accept an in-memory upload that replaces one of the …
Aug 12, 2026
9cd8e81
test(mcp-server): upload to the url the in-memory store actually hand…
Aug 12, 2026
2ea1881
fix(mcp-server): name the refused upload first when an object is missing
Aug 12, 2026
51f1c67
refactor(mcp-server): cut what the in-memory store did not need
Aug 12, 2026
4cd35f3
docs(mcp-server): name the two conditions a hosted client needs to up…
Aug 13, 2026
b8c461f
fix(mcp-server): stop claiming a checksum header the default backend …
Aug 13, 2026
31fb616
refactor(mcp-server)!: rename requestFileUpload to requestActionFileU…
Aug 13, 2026
f6ce069
fix(mcp-server): make the in-memory upload url genuinely single-use
Aug 13, 2026
d724e28
feat(mcp-server)!: enable action file uploads by default
Aug 13, 2026
24e0547
docs(mcp-server): stop naming a settings path the user may not have
Aug 13, 2026
bb62c80
docs(mcp-server): drop cloud agents from the single-instance warning
Aug 13, 2026
63062ec
fix(agent-client)!: keep getType() on the wire form, collapse it apart
Aug 13, 2026
a961a95
fix(mcp-server): survive a retry, and stop trusting a broken storage …
Aug 13, 2026
49bd1d1
docs(mcp-server): say why the store refuses early, and what getSize m…
Aug 13, 2026
f720ae5
test(agent-testing): restore the list type this package always asserted
Aug 13, 2026
cee8109
docs(mcp-server): the Claude Desktop upload is verified, not expected
Aug 14, 2026
2190fd5
fix(mcp-server): keep a required file field satisfiable, and add a wa…
Aug 14, 2026
b6f30be
docs(mcp-server): make the sha256 pin the stated default, not an aside
Aug 14, 2026
cb63a04
fix(mcp-server): review the delta the reviews had not covered
Aug 14, 2026
d6913ab
docs(mcp-server): cowork verified too, and the filename is a label
Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ lerna-debug.log
# forest-bff openapi --output default destination
openapi.json


# yarn
yarn-error.log
.vscode/settings.json
Expand Down
4 changes: 3 additions & 1 deletion packages/_example/src/forest/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ export default function makeAgent() {

return resultBuilder.value((rows?.[0]?.value as number) ?? 0);
})
.mountAiMcpServer(allowedOAuthClients ? { allowedOAuthClients } : undefined)
.mountAiMcpServer({
...(allowedOAuthClients && { allowedOAuthClients }),
})

.customizeCollection('card', customizeCard)
.customizeCollection('account', customizeAccount)
Expand Down
33 changes: 32 additions & 1 deletion packages/_example/src/forest/customizations/review.ts
Original file line number Diff line number Diff line change
@@ -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(' — '),
);
},
});
3 changes: 2 additions & 1 deletion packages/agent-bff/src/action/action-form-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bff/src/action/agent-action-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-client/src/action-fields/action-field.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
}
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-client/src/action-fields/field-form-states.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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.getTypeName(), value, name);

const fieldHasHook = field.getPlainField().hook;

Expand Down
14 changes: 13 additions & 1 deletion packages/agent-client/src/action-fields/field-getter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,19 @@ export default class FieldGetter {
return this.plainField.field;
}

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;
Comment thread
hercemer42 marked this conversation as resolved.
}
}
68 changes: 68 additions & 0 deletions packages/agent-client/src/action-fields/file-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { File } from '@forestadmin/datasource-toolkit';

import { makeDataUri } from '@forestadmin/datasource-toolkit';

function isFileType(type: string): boolean {
return type === 'File';
}

function isFileListType(type: string): boolean {
return type === 'FileList';
}

function isFile(value: unknown): value is File {
const candidate = value as File;

return (
typeof value === 'object' &&
value !== null &&
Buffer.isBuffer(candidate.buffer) &&
typeof candidate.mimeType === 'string' &&
typeof candidate.name === 'string'
);
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

function fileError(fieldName: string, detail: string): Error {
return new Error(`Field "${fieldName}" ${detail}`);
}

function encodeFileValue(value: unknown, fieldName: string): unknown {
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 fileError(
fieldName,
'expects a file: pass { buffer, mimeType, name } or a string holding a data uri.',
);
}

export default function encodeFileFieldValue(
type: string,
value: unknown,
fieldName: string,
): unknown {
if (isFileListType(type)) {
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;
}
3 changes: 2 additions & 1 deletion packages/agent-client/src/action-fields/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/agent-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ export function createRemoteAgentClient(params: {
}

export type { RecordId, SelectOptions } from './types';
export type { File } from '@forestadmin/datasource-toolkit';
Loading
Loading