Add default prefill to FormInput.collect_input (#3937)

This commit is contained in:
Jeremiah Lowin 2026-04-14 13:23:36 -04:00 committed by GitHub
commit e4bb6666ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 82 additions and 6 deletions

View file

@ -28,7 +28,10 @@ import json
from collections.abc import Callable
from typing import Any
from packaging.version import InvalidVersion, Version
try:
import prefab_ui
from prefab_ui.actions import SetState
from prefab_ui.actions.mcp import CallTool, SendMessage
from prefab_ui.app import PrefabApp
@ -49,6 +52,13 @@ except ImportError as _exc:
"FormInput requires prefab-ui. Install with: pip install 'fastmcp[apps]'"
) from _exc
# `defaults` kwarg on Form.from_model was added in prefab-ui 0.19.1. Gate on
# version so that older prefab-ui keeps working — `default` silently no-ops.
try:
_FORM_SUPPORTS_DEFAULTS = Version(prefab_ui.__version__) >= Version("0.19.1")
except InvalidVersion:
_FORM_SUPPORTS_DEFAULTS = False
import pydantic
from fastmcp.apps.app import FastMCPApp
@ -161,6 +171,7 @@ class FormInput(FastMCPApp):
prompt: str,
title: str | None = None,
submit_text: str | None = None,
default: dict[str, Any] | None = None,
) -> PrefabApp:
"""Collect structured input from the user.
@ -168,6 +179,13 @@ class FormInput(FastMCPApp):
prompt: Tell the user what you need and why.
title: Optional heading for the form card.
submit_text: Optional label for the submit button.
default: Optional suggested response a partial dict of form
field values keyed by field name. The form renders with
those values pre-filled so the user can confirm or edit
rather than start from a blank form. Use this when you
already know (or can infer) what the answer should be.
Requires prefab-ui>=0.19.1; silently ignored on older
versions.
"""
_title = title or provider._title
_submit = submit_text or provider._submit_text
@ -188,16 +206,19 @@ class FormInput(FastMCPApp):
SendMessage(RESULT), # ty:ignore[invalid-argument-type]
)
Form.from_model(
model,
submit_label=_submit,
on_submit=[
from_model_kwargs: dict[str, Any] = {
"submit_label": _submit,
"on_submit": [
CallTool(
"submit_form",
on_success=on_success_actions,
),
],
)
}
if default and _FORM_SUPPORTS_DEFAULTS:
from_model_kwargs["defaults"] = default
Form.from_model(model, **from_model_kwargs)
with CardFooter(), If(STATE.submitted):
Muted("Submitted.")

View file

@ -6,7 +6,11 @@ import pydantic
import pytest
from fastmcp import FastMCP
from fastmcp.apps.form import FormInput, _backfill_boolean_defaults
from fastmcp.apps.form import (
_FORM_SUPPORTS_DEFAULTS,
FormInput,
_backfill_boolean_defaults,
)
from fastmcp.server.providers.addressing import hashed_backend_name
@ -144,6 +148,57 @@ class TestFormInputProvider:
assert "collect_address" in tool_names
class TestCollectInputDefault:
"""The `default` arg pre-fills the form for the user to confirm/edit."""
@pytest.mark.skipif(
not _FORM_SUPPORTS_DEFAULTS,
reason="prefab-ui<0.19.1 does not support Form.from_model(defaults=...)",
)
async def test_default_prefills_form_fields(self):
server = FastMCP("test", providers=[FormInput(model=Contact)])
result = await server.call_tool(
"collect_contact",
{
"prompt": "Confirm your details",
"default": {"name": "Alice", "email": "alice@example.com"},
},
)
# The form view should serialize with the prefilled values.
payload = json.dumps(result.structured_content)
assert "Alice" in payload
assert "alice@example.com" in payload
async def test_default_absent_still_renders_blank_form(self):
"""Sanity: omitting `default` keeps the existing blank-form behavior."""
server = FastMCP("test", providers=[FormInput(model=Contact)])
result = await server.call_tool(
"collect_contact",
{"prompt": "Enter your details"},
)
assert result.structured_content is not None
async def test_default_silent_on_older_prefab(self):
"""When the sniff says False, `default` is accepted but no-ops.
This test's real value is that `default` is part of the tool
signature regardless of prefab version, so agents can always pass
it without runtime errors.
"""
server = FastMCP("test", providers=[FormInput(model=Contact)])
result = await server.call_tool(
"collect_contact",
{
"prompt": "Confirm your details",
"default": {"name": "Bob"},
},
)
assert result.structured_content is not None
class TestBackfillBooleanDefaults:
def test_missing_bool_with_default_gets_backfilled(self):
data = {"title": "Note", "content": "Body"}