mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add quiz example app, fix dev server empty string args (#3700)
This commit is contained in:
parent
85b7efd746
commit
f01d0c581c
4 changed files with 271 additions and 8 deletions
258
examples/apps/quiz/quiz_server.py
Normal file
258
examples/apps/quiz/quiz_server.py
Normal 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")
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
12
uv.lock
generated
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue