diff --git a/src/fastmcp/apps/form.py b/src/fastmcp/apps/form.py index e5e6d1151..f9b9b0a79 100644 --- a/src/fastmcp/apps/form.py +++ b/src/fastmcp/apps/form.py @@ -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.") diff --git a/tests/apps/test_form.py b/tests/apps/test_form.py index 5d3af18af..6610396b8 100644 --- a/tests/apps/test_form.py +++ b/tests/apps/test_form.py @@ -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"}