diff --git a/examples/apps/quiz/quiz_server.py b/examples/apps/quiz/quiz_server.py new file mode 100644 index 000000000..7de6f08d7 --- /dev/null +++ b/examples/apps/quiz/quiz_server.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 4c08bda00..c0f2d2881 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index c264642bd..9a3d7be2d 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -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)): diff --git a/uv.lock b/uv.lock index d7fbac6eb..38c153e92 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [