60e0ffc959
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Waiting to run
Run static analysis / static_analysis (push) Waiting to run
Tests / Tests: Python 3.10 on ubuntu-latest (push) Waiting to run
Tests / Tests: Python 3.13 on ubuntu-latest (push) Waiting to run
Tests / Tests: Python 3.10 on windows-latest (push) Waiting to run
Tests / Tests with lowest-direct dependencies (push) Waiting to run
Tests / Package install smoke (push) Waiting to run
Upgrade checks / Static analysis (push) Waiting to run
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Waiting to run
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Waiting to run
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Waiting to run
Upgrade checks / Integration tests (push) Waiting to run
Upgrade checks / Notify on failure (push) Blocked by required conditions
Upgrade checks / Close issue on success (push) Blocked by required conditions
Update MCPServerConfig Schema / update-config-schema (push) Waiting to run
Update SDK Documentation / update-sdk-docs (push) Waiting to run
Tests / MCP conformance tests (push) Waiting to run
Tests / Integration tests (push) Waiting to run
226 lines
7.9 KiB
Python
226 lines
7.9 KiB
Python
"""Tests for the FormInput provider."""
|
|
|
|
import json
|
|
|
|
import pydantic
|
|
import pytest
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.apps.form import (
|
|
_FORM_SUPPORTS_DEFAULTS,
|
|
FormInput,
|
|
_backfill_boolean_defaults,
|
|
)
|
|
from fastmcp.server.providers.addressing import hashed_backend_name
|
|
|
|
|
|
class Contact(pydantic.BaseModel):
|
|
name: str
|
|
email: str
|
|
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)])
|
|
|
|
result = await server.call_tool(
|
|
"collect_contact",
|
|
{"prompt": "Enter your details"},
|
|
)
|
|
assert result.structured_content is not None
|
|
|
|
async def test_tool_name_derived_from_model(self):
|
|
server = FastMCP("test", providers=[FormInput(model=Contact)])
|
|
|
|
tools = await server.list_tools()
|
|
tool_names = [t.name for t in tools]
|
|
assert "collect_contact" in tool_names
|
|
|
|
async def test_custom_tool_name(self):
|
|
server = FastMCP(
|
|
"test",
|
|
providers=[FormInput(model=Contact, tool_name="new_contact")],
|
|
)
|
|
|
|
tools = await server.list_tools()
|
|
tool_names = [t.name for t in tools]
|
|
assert "new_contact" in tool_names
|
|
|
|
async def test_submit_validates_and_returns_json(self):
|
|
server = FastMCP("test", providers=[FormInput(model=Contact)])
|
|
|
|
result = await server.call_tool(
|
|
hashed_backend_name("Contact", "submit_form"),
|
|
{"data": {"name": "Alice", "email": "alice@example.com"}},
|
|
)
|
|
text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
parsed = json.loads(text)
|
|
assert parsed["name"] == "Alice"
|
|
assert parsed["email"] == "alice@example.com"
|
|
assert parsed["phone"] is None
|
|
|
|
async def test_submit_with_callback(self):
|
|
saved: list[Contact] = []
|
|
|
|
def on_submit(contact: Contact) -> str:
|
|
saved.append(contact)
|
|
return f"Saved {contact.name}"
|
|
|
|
server = FastMCP(
|
|
"test",
|
|
providers=[FormInput(model=Contact, on_submit=on_submit)],
|
|
)
|
|
|
|
result = await server.call_tool(
|
|
hashed_backend_name("Contact", "submit_form"),
|
|
{"data": {"name": "Bob", "email": "bob@example.com"}},
|
|
)
|
|
text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
assert "Saved Bob" in text
|
|
assert len(saved) == 1
|
|
assert saved[0].name == "Bob"
|
|
|
|
async def test_backend_tool_hidden(self):
|
|
server = FastMCP("test", providers=[FormInput(model=Contact)])
|
|
|
|
tools = await server.list_tools()
|
|
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(
|
|
hashed_backend_name("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(
|
|
hashed_backend_name("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(hashed_backend_name("NoteForm", "submit_form"), {})
|
|
|
|
async def test_multiple_models(self):
|
|
class Address(pydantic.BaseModel):
|
|
street: str
|
|
city: str
|
|
|
|
server = FastMCP(
|
|
"test",
|
|
providers=[
|
|
FormInput(model=Contact),
|
|
FormInput(model=Address),
|
|
],
|
|
)
|
|
|
|
tools = await server.list_tools()
|
|
tool_names = [t.name for t in tools]
|
|
assert "collect_contact" in tool_names
|
|
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"}
|
|
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
|