From 8ee81b3037d28a86063e763e6be8830d5b5e7b1f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:23:34 -0400 Subject: [PATCH] fix: boolean false values dropped in form submissions (#3776) * fix: boolean false values dropped in form submissions * revert apps_dev.py boolean coercion --- src/fastmcp/apps/form.py | 26 ++++++++++++++- tests/apps/test_form.py | 70 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/apps/form.py b/src/fastmcp/apps/form.py index 1d6f67b5a..e5e6d1151 100644 --- a/src/fastmcp/apps/form.py +++ b/src/fastmcp/apps/form.py @@ -54,6 +54,27 @@ import pydantic from fastmcp.apps.app import FastMCPApp +def _backfill_boolean_defaults( + model: type[pydantic.BaseModel], + data: dict[str, Any], +) -> dict[str, Any]: + """Fill in missing boolean fields with their model defaults. + + HTML checkboxes omit the field entirely when unchecked, so the + submitted data dict won't contain a key for ``False`` booleans. + This backfills those missing keys so Pydantic validation succeeds. + """ + for name, field_info in model.model_fields.items(): + if name in data: + continue + if field_info.annotation is bool: + if field_info.default is not pydantic.fields.PydanticUndefined: + data[name] = field_info.default + else: + data[name] = False + return data + + class FormInput(FastMCPApp): """A Provider that collects structured input via a Pydantic model. @@ -117,8 +138,11 @@ class FormInput(FastMCPApp): model = self._model @self.tool() - def submit_form(data: dict[str, Any]) -> str: + def submit_form(data: dict[str, Any] | None = None) -> str: """Validate and process form submission.""" + if data is None: + data = {} + data = _backfill_boolean_defaults(model, data) validated = model.model_validate(data) if provider._on_submit is not None: return provider._on_submit(validated) diff --git a/tests/apps/test_form.py b/tests/apps/test_form.py index d4db5fcd2..9f419d9b7 100644 --- a/tests/apps/test_form.py +++ b/tests/apps/test_form.py @@ -3,9 +3,10 @@ import json import pydantic +import pytest from fastmcp import FastMCP -from fastmcp.apps.form import FormInput +from fastmcp.apps.form import FormInput, _backfill_boolean_defaults class Contact(pydantic.BaseModel): @@ -14,6 +15,12 @@ class Contact(pydantic.BaseModel): phone: str | None = None +class NoteForm(pydantic.BaseModel): + title: str + content: str + archived: bool = False + + class TestFormInputProvider: async def test_collect_returns_structured_content(self): server = FastMCP("test", providers=[FormInput(model=Contact)]) @@ -82,6 +89,41 @@ class TestFormInputProvider: tool_names = [t.name for t in tools] assert "_submit_form" not in tool_names + async def test_submit_boolean_false_omitted(self): + """Unchecked checkboxes omit the field; submit_form should still succeed.""" + server = FastMCP("test", providers=[FormInput(model=NoteForm)]) + + result = await server.call_tool( + "NoteForm___submit_form", + {"data": {"title": "My Note", "content": "Hello"}}, + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + parsed = json.loads(text) + assert parsed["title"] == "My Note" + assert parsed["archived"] is False + + async def test_submit_boolean_true_preserved(self): + """When a boolean field is explicitly True, it should be preserved.""" + server = FastMCP("test", providers=[FormInput(model=NoteForm)]) + + result = await server.call_tool( + "NoteForm___submit_form", + {"data": {"title": "My Note", "content": "Hello", "archived": True}}, + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + parsed = json.loads(text) + assert parsed["archived"] is True + + async def test_submit_no_data_does_not_crash(self): + """When data is omitted entirely, the tool should not raise a missing argument error.""" + server = FastMCP("test", providers=[FormInput(model=NoteForm)]) + + # Should reach model validation (not crash with "missing required argument" + # for the data parameter itself). Pydantic will still reject missing + # required fields like title/content, but that's expected. + with pytest.raises(pydantic.ValidationError, match="title"): + await server.call_tool("NoteForm___submit_form", {}) + async def test_multiple_models(self): class Address(pydantic.BaseModel): street: str @@ -99,3 +141,29 @@ class TestFormInputProvider: tool_names = [t.name for t in tools] assert "collect_contact" in tool_names assert "collect_address" in tool_names + + +class TestBackfillBooleanDefaults: + def test_missing_bool_with_default_gets_backfilled(self): + data = {"title": "Note", "content": "Body"} + result = _backfill_boolean_defaults(NoteForm, data) + assert result["archived"] is False + + def test_present_bool_not_overwritten(self): + data = {"title": "Note", "content": "Body", "archived": True} + result = _backfill_boolean_defaults(NoteForm, data) + assert result["archived"] is True + + def test_required_bool_without_default_gets_false(self): + class FormWithRequiredBool(pydantic.BaseModel): + name: str + active: bool + + data = {"name": "Test"} + result = _backfill_boolean_defaults(FormWithRequiredBool, data) + assert result["active"] is False + + def test_non_bool_fields_untouched(self): + data = {"title": "Note"} + result = _backfill_boolean_defaults(NoteForm, data) + assert "content" not in result