fix: boolean false values dropped in form submissions (#3776)

* fix: boolean false values dropped in form submissions

* revert apps_dev.py boolean coercion
This commit is contained in:
Jeremiah Lowin 2026-04-07 11:23:34 -04:00 committed by GitHub
commit 8ee81b3037
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 94 additions and 2 deletions

View file

@ -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)

View file

@ -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