Skip to content

fix(mcp-server): accept file uploads on v1 String fields carrying the file picker widget - #1869

Merged
Scra3 merged 4 commits into
mainfrom
feat/mcp-file-upload-string-file-picker
Sep 1, 2026
Merged

fix(mcp-server): accept file uploads on v1 String fields carrying the file picker widget#1869
Scra3 merged 4 commits into
mainfrom
feat/mcp-file-upload-string-file-picker

Conversation

@Scra3

@Scra3 Scra3 commented Aug 31, 2026

Copy link
Copy Markdown
Member

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
  • encodeFileFieldValue throws when a File object reaches a non-File field

Reported by a customer on forest-rails 9.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-properties runs the type/widget validator only on the v2 widgetEdit path; the v1 widget path just checks the widget name against an allowlist and stores it on any type. Verified in production: those fields carry widgetEdit: { name: "file picker", parameters: {} }.

v2 makes the combination impossible at the TypeScript level (only FileDynamicField accepts FilePicker), so this is a v1-only compat shim, in the same family as the ones agent-client already carries (the Ruby /hooks/load 404, the snake_case operators).

What changes

FieldGetter.getEffectiveTypeName() reads String / StringList carrying that widget as File / FileList, and feeds the two existing dispatch points:

before after
type getActionForm reports String File → the model calls requestActionFileUpload
type setFieldValue encodes with String → throws File → data uri

getTypeName() is untouched — it still drives the Enum branch 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/FileList branch of encodeFileFieldValue:

  • a non-array on a ['String'] + file-picker field now throws expects a list of files: pass an array. — it used to be forwarded verbatim
  • a non-file object on a scalar String + file-picker field now throws instead of being stored as is

Plain strings still pass through untouched, so the common case (a data uri or a URL) is unaffected. Erroring matches what native File/FileList fields 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

  • A File landing on a String without 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.
  • Nothing on the customer side. On the wire both types carry a data uri, which is exactly what their handler already receives when a human drops a file in the front.
  • getType() still returns the verbatim wire value, so what loadChanges echoes back to the agent is unchanged.

Tests

agent-client 400/400 · mcp-server 1015/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/change round-trip is exercised with the data uri in the body.

get-action-form.test.ts grew ~55 lines of mock churn: its ~20 hand-rolled field mocks each needed a getEffectiveTypeName alongside their existing getTypeName, mirroring the same literal. No behaviour change in those tests.

Noted, not fixed here

ActionFieldString.fill() calls value.toString() before the guard can see the value, so a File routed through that public wrapper would be stored as '[object Object]'. Unreachable from the MCP (both entry points go through FieldFormStates.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 picker widget via getEffectiveTypeName()

  • Adds getEffectiveTypeName() to FieldGetter and ActionField to map String/StringList fields carrying the file picker widget to File/FileList; other types are unchanged.
  • FieldFormStates.setFieldValue and the get-action-form MCP tool now use the effective type instead of the declared type for encoding and serialization, so v1 file picker fields produce data URIs and report File/FileList in tool responses.
  • Updates error text in encodeFileFieldValue to suggest declaring File or using the file picker widget.
  • Behavioral Change: get-action-form tool responses now report File/FileList for v1 file picker String fields; consumers relying on the previous String/StringList type for those fields need to handle the new type.

Changes since #1869 opened

  • Introduced distinction between declared type and effective type for field type resolution in FieldGetter class [a690374]
  • Updated documentation in CLAUDE.md to explain the type name distinction for file picker widget handling [a690374]
  • Updated test fixtures across packages/agent-client to use empty object for file picker widget parameters [a690374]
  • Refined error message in encodeFileFieldValue function for invalid file assignment [a690374]

Macroscope summarized a550341.

…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>
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

PRD-1116

@qltysh

qltysh Bot commented Aug 31, 2026

Copy link
Copy Markdown

1 new issue

Tool Category Rule Count
qlty Structure Function with many returns (count = 4): encodeFileFieldValue 1

@qltysh

qltysh Bot commented Aug 31, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (4)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-client/src/action-fields/field-getter.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/action-fields/field-form-states.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/action-fields/file-value.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/action-fields/action-field.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@Scra3 Scra3 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/agent-client/src/action-fields/action-field.ts
Comment thread packages/agent-client/test/action-fields/field-getter.test.ts Outdated
alban bertolini and others added 2 commits August 31, 2026 22:29
…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>
@Scra3 Scra3 changed the title fix(agent-client): accept file uploads on v1 String fields carrying the file picker widget fix(mcp-server): accept file uploads on v1 String fields carrying the file picker widget Sep 1, 2026

@hercemer42 hercemer42 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/mcp-server/src/tools/get-action-form.ts
Comment thread packages/agent-client/src/action-fields/file-value.ts Outdated
Comment thread packages/agent-client/src/action-fields/field-getter.ts
Comment thread packages/agent-client/src/action-fields/types.ts
…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>
@Scra3
Scra3 merged commit 09d5899 into main Sep 1, 2026
33 checks passed
@Scra3
Scra3 deleted the feat/mcp-file-upload-string-file-picker branch September 1, 2026 11:25
forest-bot added a commit that referenced this pull request Sep 1, 2026
## @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))
forest-bot added a commit that referenced this pull request Sep 1, 2026
## @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
forest-bot added a commit that referenced this pull request Sep 1, 2026
## @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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants