mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
bbccc52b60
commit
8ee81b3037
2 changed files with 94 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue