A from-scratch MCP host built on FastMCP + Prefab. The dynamic right-hand panel is composed exclusively from Prefab components — no hand-written HTML/JS ever lands in the dynamic side. The agent runs a deliberate plan-then-render loop, can stack multiple widgets per turn, and produces real interactive UI (buttons, sliders, switches, accordions) — not just static reports.
┌──────────────────────────── Browser ────────────────────────────┐
│ Sidebar (20%, resizable) │ Dynamic side (Prefab only) │
│ • chat transcript │ <iframe src="/current"> │
│ • tool log + render plan box │ ↑ swapped each turn │
│ • prominent "thinking…" banner │ (one composed PrefabApp │
│ • input box, Clear All button │ page per turn) │
└────────────────┬─────────────────┴──────────────────────────────┘
│ POST /chat
▼
┌──────────────────────────────────┐
│ FastAPI host (app/main.py) │
│ + Session(agent loop) │ ← Gemini function-calling
└────────────┬─────────────────────┘
│ tool.run(args) in-process
▼
┌──────────────────────────────────┐
│ FastMCP server (app/mcp_server) │
│ data tools (3) │ ← suggest_show_names, fetch_shows,
│ render_* tools (13) │ watchlist (CRUD on watchlist.json)
└──────────────────────────────────┘
Every user turn runs in two explicit phases. This is the most important design decision in the project — it prevents the agent from silently overwriting one widget with another and gives you a visible "what is it about to draw?" trail.
Phase 1 — Plan.
Gemini sees only the data tools and one virtual terminal tool,
propose_ui(widgets=[…], reasoning="…"). The render tools are NOT exposed, so
the model literally cannot draw directly. It fetches data, manipulates the
watchlist, then declares its render plan as a list of widget specs.
Phase 2 — Render.
The server validates the plan, composes every widget in the list into ONE
PrefabApp page (stacked vertically), and serves it to the iframe. The sidebar
also displays the plan: chosen widget(s) + the model's 1-sentence reasoning.
This means a request like "show my watchlist as a table AND bar-chart their ratings" produces both views on one scrollable page — in user-asked order — rather than the second render quietly clobbering the first.
| Tool | Purpose |
|---|---|
suggest_show_names(query, count) |
Important. Gemini-backed brainstorm: vague query ("drama", "shows like Breaking Bad") → list of real show titles. Call this FIRST for any genre/mood query because the next tool is title-only. |
fetch_shows(query, limit) |
Search TVMaze by show title. Best when the query is already a specific title. |
watchlist(action, show?, show_id?) |
list / add / remove / clear against watchlist.json. |
| Tool | What you get |
|---|---|
render_show_grid |
Responsive card grid (poster, rating badge, summary) |
render_show_table |
Sortable, searchable DataTable |
render_show_detail |
Single-show page (poster + metadata + summary) |
render_bar_chart_ratings |
Bar chart of show ratings |
render_pie_chart_genres |
Pie chart of genre distribution |
render_line_chart_premiered |
Line chart of rating vs premiere date |
render_metric_dashboard |
KPI cards (totals / average / top) + table |
render_message |
Plain markdown panel for status / errors |
render_quiz ⚡ |
Interactive. Tabs per question; A/B/C/D Buttons use SetState to write the pick into client state; If/Elif/Else blocks instantly reveal a green ✅ or red ❌ card with the explanation. Includes Reset / Try-again buttons. |
render_counter ⚡ |
+ / − / Reset for tallies ("episodes watched"). |
render_rating_filter ⚡ |
Slider bound to a state key, with a live Badge of the current threshold, sitting over a searchable show table. |
render_toggle_summaries ⚡ |
A Switch whose state hides/reveals the markdown summary on every card simultaneously via If. |
render_accordion_faq ⚡ |
Collapsible Prefab Accordion — great for ranked lists, episode notes, FAQ-style breakdowns. |
⚡ = client-side state via Prefab's SetState actions. Each interactive widget
declares its initial state via a _state_* function; compose() aggregates
those into one namespaced PrefabApp(state=…) dict so multiple widgets on the
same page don't collide.
The agent is required to put answer as the verbatim text of one of the
choices (not "A"/"B"), because LLMs routinely shuffle the choices vs the
answer letter. The widget calls _resolve_correct_letter(answer, choices) to
derive the right letter at render time, with fallbacks for index / single-letter
inputs and a case-insensitive substring match. If nothing resolves, the widget
shows a yellow ⚠ data-error card naming the offending answer — so bad data is
visible instead of silently marking every pick wrong.
# 1. Conda env
conda create -n prefab-assign python=3.11 -y
conda activate prefab-assign
pip install -r requirements.txt
# 2. API key
copy .env.example .env # then fill in GEMINI_API_KEY and (optional) GEMINI_MODEL
# 3. Run
uvicorn app.main:app --reload --port 8000
# open http://localhost:8000Agent activity streams to your terminal as the turn runs:
┌─ turn[default] user: suggest 5 crime drama shows and quiz me on breaking bad
│ PHASE 1 — gather + plan
│ step 1: calling gemini-2.0-flash …
│ → tool: suggest_show_names args={'query': 'crime drama', 'count': 5}
│ ← result: ['Breaking Bad', 'The Wire', 'True Detective', …]
│ → tool: fetch_shows args={'query': 'Breaking Bad'}
│ …
│ → tool: propose_ui args={'widgets': [...], 'reasoning': '…'}
│ ★ PLAN: 2 widget(s) ['render_show_grid', 'render_quiz'] — …
│ PHASE 2 — render
│ ✓ composed 2 widget(s) (7842 bytes)
└─ turn done. tool_calls=8 plan=['render_show_grid', 'render_quiz']
suggest 6 great crime drama shows, add them to my watchlist,
then make a clickable quiz on Breaking Bad
show my watchlist as a table AND bar-chart their ratings AND
give me a counter to track episodes watched
let me filter my watchlist by minimum rating
toggle summaries off and on for my watchlist
Clear All wipes the conversation, resets watchlist.json to [], and
re-renders the empty state.
| Route | Method | Purpose |
|---|---|---|
/ |
GET | Static shell (sidebar + iframe) |
/current |
GET | Latest composed Prefab HTML for the iframe |
/chat |
POST | One full agent turn → {reply, tool_calls, transcript, plan, has_html} |
/clear |
POST | Wipe session + watchlist + dynamic side |
/transcript |
GET | Replay the current session (used on page load) |
app/
main.py FastAPI app + routes + root logging
agent.py Gemini two-phase agent loop (in-process FastMCP)
• RENDER_TOOLS / DATA_TOOLS / PROPOSE_UI
• _sanitize_schema(JSON Schema → Gemini)
• Session.turn() phases + sidebar plan capture
mcp_server.py FastMCP server: suggest_show_names, fetch_shows,
watchlist, 13× render_* tools
widgets.py Prefab widget builders
• _section_* (emit components into current context)
• _state_* (initial client-side state per widget)
• compose([{widget, args}, ...]) → ONE PrefabApp.html()
• _resolve_correct_letter for the quiz contract
static/
index.html Shell — sidebar + iframe + chat input (only hand-written HTML)
app.js Resize, optimistic user bubble, plan box, clear handler
style.css Dark theme + pulsing "thinking…" banner
watchlist.json Local persistence (wiped on /clear)
.env.example GEMINI_API_KEY, GEMINI_MODEL
requirements.txt
- One renderer per turn. The dynamic side reloads a single
PrefabApppage inside the iframe each turn. There's no diffing or partial updates — the agent stacks every widget it wants into the plan list, and the server emits one page from that. Interactive state (slider position, picked quiz answer, counter value) persists within a page but is reset when the next turn composes a new page. This is by design — the agent owns the layout. - No prefab serve. We use FastAPI + a hand-rolled static shell because we
need full control of the sidebar/agent loop UI. The dynamic side is still
100% Prefab —
PrefabApp.html()is the only HTML emitter inside the iframe. - In-process MCP. The FastAPI host and the FastMCP server live in the same
Python process; tools are invoked via
tool.run(args). No stdio transport, no separate MCP server process. Trivially swappable if you want one.