fix(mcp-server): accept file uploads on v1 String fields carrying the file picker widget - #1869
Conversation
…he file picker widget Legacy v1 lianas declare action file inputs as `type: 'String'` with `widget: 'file picker'` — the pattern our own docs still recommend for multi-file upload. The front dispatches on the widget and works; the MCP dispatches on the declared type and does not: getActionForm reports "String" so the model never calls requestActionFileUpload, and encodeFileFieldValue throws when a File object reaches a non-File field. The SaaS lets the combination through: generate-widget-properties applies the type/widget validator only on the v2 `widgetEdit` path, so the v1 `widget` path stores `file picker` on any type. v2 makes the combination impossible at the type level, so this is a v1-only compat shim. FieldGetter gains getEffectiveTypeName(), which reads String and StringList carrying that widget as File and FileList. It feeds the two existing dispatch points — the type getActionForm reports, and the type setFieldValue encodes with. getTypeName() is untouched: it still drives the Enum branch and reports the wire type. A File landing on a String without the widget still throws, now with a message naming the fix. On the wire both types carry a data uri, which is what the customer's handler already receives from the front, so no agent-side change. fixes PRD-1116 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 new issue
|
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (4)
🛟 Help
|
Scra3
left a comment
There was a problem hiding this comment.
Spec (PRD-1116): conforms. The mapping is exactly the two cases the ticket prescribes, both dispatch points are the ones it names, getTypeName() is untouched, the anti-corruption guard still fires for a String without the widget, and the two tests the ticket asked for by name are present and strong — the /hooks/change round-trip pins the request body and the returned file_0: pdfDataUri, and the Ruby static-form case proves widgetEdit survives applyFallbackForm.
I verified the two claims the change rests on rather than taking them from the description: resolveUploadedFileValues substitutes upload handles without ever reading the field type, so the handle flow is preserved and the file still never enters the model's context; and the ~55 lines of mock churn in get-action-form.test.ts are behaviour-neutral — every mock mirrors its getTypeName literal except the three new cases, checked mechanically.
Claude Opus 5 (claude-opus-5[1m]): Preferential — the PR description says nothing about a behaviour change it introduces.
Applies to: the PR description
A caller that passes a non-array to a ['String'] + file-picker field used to have the value forwarded verbatim (type: 'StringList' matched neither file branch, so encodeFileFieldValue returned it); it now throws expects a list of files: pass an array. Same shape for a non-file object on a scalar String + file-picker field. Plain strings still pass through, so the common URL case is unaffected.
Erroring is the better behaviour — it matches what native File/FileList fields already did. But ['String'] + file picker is precisely the pattern Forest's own legacy docs recommend for multi-file upload, so this tightens validation on the exact shape the PR targets, and "no customer-side change" in the description currently reads as covering it. Worth one sentence.
Claude Opus 5 (claude-opus-5[1m]): Preferential — a documented statement goes stale with this PR.
Applies to: docs.forest.app → /product/embed/mcp-server.mdx
That page scopes the upload path to File fields in two places — the tool table ("Get an upload destination for an action's File or FileList field") and the section intro ("Actions with File fields work over MCP out of the box"). After this PR a String + file-picker field also participates, so both sentences under-describe the surface and a reader has no way to learn the combination is supported.
Distinct from the legacy ['String'] doc pages PRD-1116 already scopes out to a follow-up: this staleness is created by this change, not inherited.
…n FieldGetter `get-action-form.ts` reads the reported type off ActionField instances — Action.getFields() maps the FieldGetters into typed subclasses — but only FieldGetter's mapping was covered. Reverting ActionField's delegation to getTypeName() left the whole suite green while getActionForm went back to reporting "String" for a v1 file-picker field. Qlty saw the same hole from the other side: 0% diff coverage on action-field.ts. Three cases in the existing base-class block, mirroring how it already covers getType/getValue/isRequired. The same mutation now fails two tests. Also renames a test that stated the delegation rather than the behaviour it asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getField() dispatches on the raw wire type, so a v1 file-picker field is built as ActionFieldString and reports File only through the inherited delegation. Nothing covered that interaction: dispatching on the effective type instead would look reasonable and break, since no ActionFieldFile exists. Drives a real FieldFormStates from a v1-shaped form and asserts the field that comes back is an ActionFieldString whose getType() is String and whose getEffectiveTypeName() is File. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hercemer42
left a comment
There was a problem hiding this comment.
Claude Opus 5 (claude-opus-5[1m]): Spec (PRD-1116): conforms. getEffectiveTypeName() implements the ticket's mapping table verbatim and is wired at exactly the two dispatch points it names — get-action-form.ts:128 (read) and field-form-states.ts:89 (write) — with getTypeName() left driving the Enum branch and getType() left as the wire value. I checked the premise against the two v1 lianas rather than the stored apimap alone: forest-rails (app/helpers/forest_liana/widgets_helper.rb, set_field_widget) and forest-express (src/utils/widgets.js, setFieldWidget) both rewrite widget: 'file picker' into widgetEdit: { name: 'file picker', parameters: {} } and delete the widget key, on the /hooks/load and /hooks/change responses. So the key this reads is present on both dynamic and static form paths, and it survives the change-hook round trip — the mid-form reversion this design would otherwise be exposed to does not occur.
Claude Opus 5 (claude-opus-5[1m]): Preferential
Applies to: the PR as a whole (packages/workflow-executor/src/adapters/agent-client-agent-port.ts:319, not in this diff)
The same v1 field is now File to an MCP model and still String to the workflow AI, so a file-picker field stays unfillable from a trigger-record-action step. agent-client-agent-port.ts:319 reports field.getType(), and that value goes into the form-fill prompt at trigger-record-action-step-executor.ts:308; under its never-guess rule the model either omits a required file field — the operator sees a generic form-validation failure with no mention of files — or invents a plausible string.
Not a defect in this PR: getType() is deliberately the wire value there, because agent-bff and workflow-executor put it straight into API responses, and changing it would change those response shapes. But the description's "what does not change" section reads as though the agent-client change reaches every consumer, and this is the one place it deliberately does not. Worth a line in the body or a follow-up ticket so it is inherited on purpose.
…nest
Four points from review.
The throw reached the model telling it to change the action's schema, which
only the agent's author can do. resolveUploadedFileValues substitutes by
value shape, not by field type, so a handle the model puts in the wrong
field lands here as a real File on a String — and the recovery it needs is
"send the file to a File field", not a schema report. That half now leads.
widgetEdit.parameters.static was required, but no file picker carries it: v1
emits {} and v2 the upload constraints. Six fixtures were inventing one to
typecheck while the one written from the real payload used {}. Making it
optional lets every fixture state what production sends.
getEffectiveTypeName carried no comment in a file whose whole job is to
disambiguate three accessors, and getTypeName's comment had gone half-false:
reporting to a reader moved here, only the Enum dispatch stayed behind.
mcp-server's CLAUDE.md still named getTypeName as what the server reports to
a model, so the next agent editing that tool was told to undo this fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## @forestadmin/forestadmin-client [1.43.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.43.2...@forestadmin/forestadmin-client@1.43.3) (2026-09-01) ### Bug Fixes * **mcp-server:** accept file uploads on v1 String fields carrying the file picker widget ([#1869](#1869)) ([09d5899](09d5899))
## @forestadmin/agent-client [1.15.1](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-client@1.15.0...@forestadmin/agent-client@1.15.1) (2026-09-01) ### Bug Fixes * **mcp-server:** accept file uploads on v1 String fields carrying the file picker widget ([#1869](#1869)) ([09d5899](09d5899)) ### Dependencies * **@forestadmin/forestadmin-client:** upgraded to 1.43.3
## @forestadmin/mcp-server [1.24.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.24.1...@forestadmin/mcp-server@1.24.2) (2026-09-01) ### Bug Fixes * **mcp-server:** accept file uploads on v1 String fields carrying the file picker widget ([#1869](#1869)) ([09d5899](09d5899)) ### Dependencies * **@forestadmin/agent-client:** upgraded to 1.15.1 * **@forestadmin/forestadmin-client:** upgraded to 1.43.3

Legacy v1 lianas declare action file inputs as
type: 'String'withwidget: 'file picker'— the pattern our own docs still recommend for multi-file upload. The front dispatches on the widget and works. The MCP dispatches on the declared type and does not:getActionFormreports"String", so the model never callsrequestActionFileUploadencodeFileFieldValuethrows when aFileobject reaches a non-FilefieldReported by a customer on
forest-rails9.15.8 whose KYC/KYB/claim upload actions are all built this way.Why the combination exists
It is a validation hole on our side, not a customer mistake. In
private-api,generate-widget-propertiesruns the type/widget validator only on the v2widgetEditpath; the v1widgetpath just checks the widget name against an allowlist and stores it on any type. Verified in production: those fields carrywidgetEdit: { name: "file picker", parameters: {} }.v2 makes the combination impossible at the TypeScript level (only
FileDynamicFieldacceptsFilePicker), so this is a v1-only compat shim, in the same family as the onesagent-clientalready carries (the Ruby/hooks/load404, the snake_case operators).What changes
FieldGetter.getEffectiveTypeName()readsString/StringListcarrying that widget asFile/FileList, and feeds the two existing dispatch points:getActionFormreportsStringFile→ the model callsrequestActionFileUploadsetFieldValueencodes withString→ throwsFile→ data urigetTypeName()is untouched — it still drives theEnumbranch and reports the wire type.One behaviour change worth knowing
Value-shape validation on file-picker fields gets stricter, because they now take the
File/FileListbranch ofencodeFileFieldValue:['String']+ file-picker field now throwsexpects a list of files: pass an array.— it used to be forwarded verbatimString+ file-picker field now throws instead of being stored as isPlain strings still pass through untouched, so the common case (a data uri or a URL) is unaffected. Erroring matches what native
File/FileListfields already did, and replaces a silently wrong-shaped payload with a message — but['String']+ file picker is the shape the legacy docs recommend for multi-file upload, so it is called out rather than buried.What does not change
Filelanding on aStringwithout the widget still throws. That guard prevents a file being JSON-serialized into a column as{"buffer":{"type":"Buffer",...}}with no error. Covered by a test.getType()still returns the verbatim wire value, so whatloadChangesechoes back to the agent is unchanged.Tests
agent-client400/400 ·mcp-server1015/1015 · lint 0 error.New coverage: the two mappings and every negative case (no widget, other widget, non-String type); the guard still firing; list encoding; and the real customer shape — a file field carrying
hook: "onFieldChanged", so the/hooks/changeround-trip is exercised with the data uri in the body.get-action-form.test.tsgrew ~55 lines of mock churn: its ~20 hand-rolled field mocks each needed agetEffectiveTypeNamealongside their existinggetTypeName, mirroring the same literal. No behaviour change in those tests.Noted, not fixed here
ActionFieldString.fill()callsvalue.toString()before the guard can see the value, so aFilerouted through that public wrapper would be stored as'[object Object]'. Unreachable from the MCP (both entry points go throughFieldFormStates.setFieldValue), so it is out of scope — worth its own ticket.fixes PRD-1116
🤖 Generated with Claude Code
Note
Accept file uploads on v1 String fields with
file pickerwidget viagetEffectiveTypeName()getEffectiveTypeName()toFieldGetterandActionFieldto mapString/StringListfields carrying thefile pickerwidget toFile/FileList; other types are unchanged.FieldFormStates.setFieldValueand theget-action-formMCP tool now use the effective type instead of the declared type for encoding and serialization, so v1 file picker fields produce data URIs and reportFile/FileListin tool responses.encodeFileFieldValueto suggest declaringFileor using thefile pickerwidget.get-action-formtool responses now reportFile/FileListfor v1 file picker String fields; consumers relying on the previousString/StringListtype for those fields need to handle the new type.Changes since #1869 opened
FieldGetterclass [a690374]CLAUDE.mdto explain the type name distinction for file picker widget handling [a690374]packages/agent-clientto use empty object for file picker widget parameters [a690374]encodeFileFieldValuefunction for invalid file assignment [a690374]Macroscope summarized a550341.