Skip to content

Commit f01d0c5

Browse files
authored
Add quiz example app, fix dev server empty string args (#3700)
1 parent 85b7efd commit f01d0c5

File tree

4 files changed

+271
-8
lines changed

4 files changed

+271
-8
lines changed

examples/apps/quiz/quiz_server.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""Quiz / trivia app — a FastMCPApp example with multi-turn state.
2+
3+
Demonstrates building state over a conversation:
4+
- The LLM generates quiz questions and calls `take_quiz` to launch the UI
5+
- The user answers via multiple-choice buttons (no forms)
6+
- Each answer calls `submit_answer`, which returns correctness + updated score
7+
- After the final question, a SendMessage pushes the score back to the LLM
8+
9+
Usage:
10+
uv run python quiz_server.py
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from prefab_ui.actions import SetState, ShowToast
16+
from prefab_ui.actions.mcp import CallTool, SendMessage
17+
from prefab_ui.app import PrefabApp
18+
from prefab_ui.components import (
19+
Badge,
20+
Button,
21+
Card,
22+
Column,
23+
Heading,
24+
If,
25+
Muted,
26+
Progress,
27+
Row,
28+
Text,
29+
)
30+
from prefab_ui.rx import ERROR, RESULT, Rx
31+
32+
from fastmcp import FastMCP, FastMCPApp
33+
34+
app = FastMCPApp("Quiz")
35+
36+
DEFAULT_QUESTIONS = [
37+
{
38+
"question": "What is the capital of Australia?",
39+
"options": ["Sydney", "Melbourne", "Canberra", "Perth"],
40+
"correct": 2,
41+
},
42+
{
43+
"question": "Which planet has the most moons?",
44+
"options": ["Jupiter", "Saturn", "Uranus", "Neptune"],
45+
"correct": 1,
46+
},
47+
{
48+
"question": "What year did the Berlin Wall fall?",
49+
"options": ["1987", "1989", "1991", "1993"],
50+
"correct": 1,
51+
},
52+
{
53+
"question": "Which element has the chemical symbol 'Au'?",
54+
"options": ["Silver", "Aluminum", "Gold", "Argon"],
55+
"correct": 2,
56+
},
57+
{
58+
"question": "What is the deepest ocean?",
59+
"options": ["Atlantic", "Indian", "Arctic", "Pacific"],
60+
"correct": 3,
61+
},
62+
]
63+
64+
65+
# ---------------------------------------------------------------------------
66+
# Backend tool — grade an answer and advance state
67+
# ---------------------------------------------------------------------------
68+
69+
70+
@app.tool()
71+
def submit_answer(
72+
question_index: int,
73+
selected: int,
74+
correct: int,
75+
total_questions: int,
76+
current_score: int,
77+
) -> dict:
78+
"""Grade an answer and return the updated quiz state.
79+
80+
Returns a dict with:
81+
- is_correct: whether the selected answer matched the correct index
82+
- new_score: the updated cumulative score
83+
- answered_index: the question that was just answered
84+
- finished: whether this was the last question
85+
"""
86+
is_correct = selected == correct
87+
new_score = current_score + (1 if is_correct else 0)
88+
finished = (question_index + 1) >= total_questions
89+
return {
90+
"is_correct": is_correct,
91+
"new_score": new_score,
92+
"answered_index": question_index,
93+
"finished": finished,
94+
}
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# UI entry point — the LLM calls this with a topic and generated questions
99+
# ---------------------------------------------------------------------------
100+
101+
102+
@app.ui()
103+
def take_quiz(
104+
topic: str = "General Knowledge",
105+
questions: list[dict] | None = None,
106+
) -> PrefabApp:
107+
"""Launch a quiz UI.
108+
109+
The LLM generates the questions and passes them in:
110+
- topic: displayed as the heading (e.g. "World Capitals")
111+
- questions: list of dicts, each with:
112+
- "question": the question text
113+
- "options": list of answer strings
114+
- "correct": index of the correct option
115+
116+
If no questions are provided, a built-in set is used.
117+
"""
118+
if questions is None:
119+
questions = DEFAULT_QUESTIONS
120+
total = len(questions)
121+
score = Rx("score")
122+
current_q = Rx("current_question")
123+
answered = Rx("answered")
124+
125+
with Column(gap=6, css_class="p-6 max-w-2xl") as view:
126+
Heading(f"Quiz: {topic}")
127+
128+
with Row(gap=3, align="center"):
129+
Badge(f"{score}/{total} correct", variant="secondary")
130+
Progress(value=current_q, max=total, size="sm")
131+
132+
for i, q in enumerate(questions):
133+
visible = current_q == i
134+
options = q["options"]
135+
correct_idx = q["correct"]
136+
137+
with If(visible):
138+
with Card():
139+
with Column(gap=4, css_class="p-4"):
140+
Text(
141+
f"Question {i + 1} of {total}",
142+
css_class="text-sm font-medium text-muted-foreground",
143+
)
144+
Heading(q["question"], level=3)
145+
146+
with If(~answered):
147+
with Column(gap=2):
148+
for opt_idx, option in enumerate(options):
149+
on_success_actions = [
150+
SetState("answered", True),
151+
SetState(
152+
"last_correct",
153+
RESULT.is_correct,
154+
),
155+
SetState("score", RESULT.new_score),
156+
]
157+
is_last = (i + 1) >= total
158+
if is_last:
159+
on_success_actions.append(
160+
SetState("finished", True),
161+
)
162+
163+
Button(
164+
option,
165+
variant="outline",
166+
css_class="w-full justify-start",
167+
on_click=CallTool(
168+
submit_answer,
169+
arguments={
170+
"question_index": i,
171+
"selected": opt_idx,
172+
"correct": correct_idx,
173+
"total_questions": total,
174+
"current_score": str(score),
175+
},
176+
on_success=on_success_actions,
177+
on_error=ShowToast(
178+
ERROR,
179+
variant="error",
180+
),
181+
),
182+
)
183+
184+
with If(answered):
185+
with Column(gap=2):
186+
for opt_idx, option in enumerate(options):
187+
if opt_idx == correct_idx:
188+
Button(
189+
f"{option}",
190+
variant="success",
191+
css_class="w-full justify-start",
192+
disabled=True,
193+
)
194+
else:
195+
Button(
196+
option,
197+
variant="ghost",
198+
css_class="w-full justify-start opacity-50",
199+
disabled=True,
200+
)
201+
202+
with If(Rx("last_correct")):
203+
Badge("Correct!", variant="success")
204+
with If(~Rx("last_correct")):
205+
Badge(
206+
f"Incorrect — answer: {options[correct_idx]}",
207+
variant="destructive",
208+
)
209+
210+
with If(answered & ~Rx("finished")):
211+
Button(
212+
"Next Question",
213+
variant="default",
214+
on_click=[
215+
SetState("current_question", current_q + 1),
216+
SetState("answered", False),
217+
SetState("last_correct", False),
218+
],
219+
)
220+
221+
with If(Rx("finished") & answered):
222+
with Card(css_class="border-2 border-primary"):
223+
with Column(gap=3, css_class="p-4 items-center text-center"):
224+
Heading("Quiz Complete!", level=2)
225+
Text(
226+
f"{score}/{total} correct",
227+
css_class="text-2xl font-bold",
228+
)
229+
Progress(
230+
value=score,
231+
max=total,
232+
variant="success",
233+
size="lg",
234+
)
235+
Muted("Click below to send your results to the conversation.")
236+
Button(
237+
"Send Results",
238+
variant="default",
239+
on_click=SendMessage(
240+
f'Quiz complete! Topic: "{topic}" '
241+
f"— Final score: {score}/{total} correct.",
242+
),
243+
)
244+
245+
initial_state = {
246+
"score": 0,
247+
"current_question": 0,
248+
"answered": False,
249+
"last_correct": False,
250+
"finished": False,
251+
}
252+
return PrefabApp(view=view, state=initial_state)
253+
254+
255+
mcp = FastMCP("Quiz Server", providers=[app])
256+
257+
if __name__ == "__main__":
258+
mcp.run(transport="http")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ classifiers = [
5353

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

src/fastmcp/cli/apps_dev.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1440,7 +1440,12 @@ async def api_launch(request: Request) -> Response:
14401440
for k, v in data.items():
14411441
if isinstance(v, str):
14421442
stripped = v.strip()
1443-
if stripped and stripped[0] in ("{", "["):
1443+
# Skip empty strings — the form sends them for
1444+
# unfilled optional fields, but they'll fail
1445+
# validation against non-string types.
1446+
if not stripped:
1447+
continue
1448+
if stripped[0] in ("{", "["):
14441449
try:
14451450
parsed = json.loads(stripped)
14461451
if isinstance(parsed, (dict, list)):

uv.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)