Add quiz example app, fix dev server empty string args (#3700)

This commit is contained in:
Jeremiah Lowin 2026-03-29 21:17:53 -04:00 committed by GitHub
commit f01d0c581c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 271 additions and 8 deletions

View file

@ -0,0 +1,258 @@
"""Quiz / trivia app — a FastMCPApp example with multi-turn state.
Demonstrates building state over a conversation:
- The LLM generates quiz questions and calls `take_quiz` to launch the UI
- The user answers via multiple-choice buttons (no forms)
- Each answer calls `submit_answer`, which returns correctness + updated score
- After the final question, a SendMessage pushes the score back to the LLM
Usage:
uv run python quiz_server.py
"""
from __future__ import annotations
from prefab_ui.actions import SetState, ShowToast
from prefab_ui.actions.mcp import CallTool, SendMessage
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
Badge,
Button,
Card,
Column,
Heading,
If,
Muted,
Progress,
Row,
Text,
)
from prefab_ui.rx import ERROR, RESULT, Rx
from fastmcp import FastMCP, FastMCPApp
app = FastMCPApp("Quiz")
DEFAULT_QUESTIONS = [
{
"question": "What is the capital of Australia?",
"options": ["Sydney", "Melbourne", "Canberra", "Perth"],
"correct": 2,
},
{
"question": "Which planet has the most moons?",
"options": ["Jupiter", "Saturn", "Uranus", "Neptune"],
"correct": 1,
},
{
"question": "What year did the Berlin Wall fall?",
"options": ["1987", "1989", "1991", "1993"],
"correct": 1,
},
{
"question": "Which element has the chemical symbol 'Au'?",
"options": ["Silver", "Aluminum", "Gold", "Argon"],
"correct": 2,
},
{
"question": "What is the deepest ocean?",
"options": ["Atlantic", "Indian", "Arctic", "Pacific"],
"correct": 3,
},
]
# ---------------------------------------------------------------------------
# Backend tool — grade an answer and advance state
# ---------------------------------------------------------------------------
@app.tool()
def submit_answer(
question_index: int,
selected: int,
correct: int,
total_questions: int,
current_score: int,
) -> dict:
"""Grade an answer and return the updated quiz state.
Returns a dict with:
- is_correct: whether the selected answer matched the correct index
- new_score: the updated cumulative score
- answered_index: the question that was just answered
- finished: whether this was the last question
"""
is_correct = selected == correct
new_score = current_score + (1 if is_correct else 0)
finished = (question_index + 1) >= total_questions
return {
"is_correct": is_correct,
"new_score": new_score,
"answered_index": question_index,
"finished": finished,
}
# ---------------------------------------------------------------------------
# UI entry point — the LLM calls this with a topic and generated questions
# ---------------------------------------------------------------------------
@app.ui()
def take_quiz(
topic: str = "General Knowledge",
questions: list[dict] | None = None,
) -> PrefabApp:
"""Launch a quiz UI.
The LLM generates the questions and passes them in:
- topic: displayed as the heading (e.g. "World Capitals")
- questions: list of dicts, each with:
- "question": the question text
- "options": list of answer strings
- "correct": index of the correct option
If no questions are provided, a built-in set is used.
"""
if questions is None:
questions = DEFAULT_QUESTIONS
total = len(questions)
score = Rx("score")
current_q = Rx("current_question")
answered = Rx("answered")
with Column(gap=6, css_class="p-6 max-w-2xl") as view:
Heading(f"Quiz: {topic}")
with Row(gap=3, align="center"):
Badge(f"{score}/{total} correct", variant="secondary")
Progress(value=current_q, max=total, size="sm")
for i, q in enumerate(questions):
visible = current_q == i
options = q["options"]
correct_idx = q["correct"]
with If(visible):
with Card():
with Column(gap=4, css_class="p-4"):
Text(
f"Question {i + 1} of {total}",
css_class="text-sm font-medium text-muted-foreground",
)
Heading(q["question"], level=3)
with If(~answered):
with Column(gap=2):
for opt_idx, option in enumerate(options):
on_success_actions = [
SetState("answered", True),
SetState(
"last_correct",
RESULT.is_correct,
),
SetState("score", RESULT.new_score),
]
is_last = (i + 1) >= total
if is_last:
on_success_actions.append(
SetState("finished", True),
)
Button(
option,
variant="outline",
css_class="w-full justify-start",
on_click=CallTool(
submit_answer,
arguments={
"question_index": i,
"selected": opt_idx,
"correct": correct_idx,
"total_questions": total,
"current_score": str(score),
},
on_success=on_success_actions,
on_error=ShowToast(
ERROR,
variant="error",
),
),
)
with If(answered):
with Column(gap=2):
for opt_idx, option in enumerate(options):
if opt_idx == correct_idx:
Button(
f"{option}",
variant="success",
css_class="w-full justify-start",
disabled=True,
)
else:
Button(
option,
variant="ghost",
css_class="w-full justify-start opacity-50",
disabled=True,
)
with If(Rx("last_correct")):
Badge("Correct!", variant="success")
with If(~Rx("last_correct")):
Badge(
f"Incorrect — answer: {options[correct_idx]}",
variant="destructive",
)
with If(answered & ~Rx("finished")):
Button(
"Next Question",
variant="default",
on_click=[
SetState("current_question", current_q + 1),
SetState("answered", False),
SetState("last_correct", False),
],
)
with If(Rx("finished") & answered):
with Card(css_class="border-2 border-primary"):
with Column(gap=3, css_class="p-4 items-center text-center"):
Heading("Quiz Complete!", level=2)
Text(
f"{score}/{total} correct",
css_class="text-2xl font-bold",
)
Progress(
value=score,
max=total,
variant="success",
size="lg",
)
Muted("Click below to send your results to the conversation.")
Button(
"Send Results",
variant="default",
on_click=SendMessage(
f'Quiz complete! Topic: "{topic}" '
f"— Final score: {score}/{total} correct.",
),
)
initial_state = {
"score": 0,
"current_question": 0,
"answered": False,
"last_correct": False,
"finished": False,
}
return PrefabApp(view=view, state=initial_state)
mcp = FastMCP("Quiz Server", providers=[app])
if __name__ == "__main__":
mcp.run(transport="http")

View file

@ -53,7 +53,7 @@ classifiers = [
[project.optional-dependencies]
anthropic = ["anthropic>=0.48.0"]
apps = ["prefab-ui>=0.17.0"]
apps = ["prefab-ui>=0.18.0"]
# PyJWT floor: transitive via msal; CVE-2026-32597 affects <= 2.11.0
azure = ["azure-identity>=1.16.0", "PyJWT>=2.12.0"]
code-mode = ["pydantic-monty==0.0.8"]

View file

@ -1440,7 +1440,12 @@ def _make_dev_app(
for k, v in data.items():
if isinstance(v, str):
stripped = v.strip()
if stripped and stripped[0] in ("{", "["):
# Skip empty strings — the form sends them for
# unfilled optional fields, but they'll fail
# validation against non-string types.
if not stripped:
continue
if stripped[0] in ("{", "["):
try:
parsed = json.loads(stripped)
if isinstance(parsed, (dict, list)):

12
uv.lock generated
View file

@ -878,7 +878,7 @@ requires-dist = [
{ name = "opentelemetry-api", specifier = ">=1.20.0" },
{ name = "packaging", specifier = ">=24.0" },
{ name = "platformdirs", specifier = ">=4.0.0" },
{ name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.17.0" },
{ name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.18.0" },
{ name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], specifier = ">=0.4.4,<0.5.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
{ name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.8" },
@ -1811,16 +1811,16 @@ wheels = [
[[package]]
name = "prefab-ui"
version = "0.17.0"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cyclopts" },
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/63/e974f66befb851801dbf80e747a787efa59fb7d335ed3fb2e0d0d6f39b0e/prefab_ui-0.17.0.tar.gz", hash = "sha256:c3b2e9706349d013966d317abfa08a82b128f6ecddc8490e8bba73b3a6e3d16c", size = 3993324, upload-time = "2026-03-29T22:35:41.489Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/a3/25fe72b9887d9c2daa0ec5e79a7971a67aad31a6f71d634e23da662343ad/prefab_ui-0.18.0.tar.gz", hash = "sha256:f72e241f52f4720baac670f8527c773e1c1f4b558bce4f77097441eecbb51b9e", size = 3998186, upload-time = "2026-03-30T01:13:33.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/36/994783fc52dfaef0123d740cefcbbc2cdd201389b5ae60e3d33110e34042/prefab_ui-0.17.0-py3-none-any.whl", hash = "sha256:9f6a5d8b659acceb2b9da7e3cf1df8a207e5ab87df4f13cd580915443c6816cc", size = 1823254, upload-time = "2026-03-29T22:35:42.953Z" },
{ url = "https://files.pythonhosted.org/packages/c0/dd/28be02a264c59d64086122c8b0f9fa99fc52e040682358e5e08219846961/prefab_ui-0.18.0-py3-none-any.whl", hash = "sha256:c9d01bd423b0d5bf103d9a0e6cfac135bd973d416297c32a5bbccc182161cace", size = 1824803, upload-time = "2026-03-30T01:13:31.243Z" },
]
[[package]]
@ -2894,8 +2894,8 @@ name = "taskgroup"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "exceptiongroup" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" }
wheels = [