Skip to content

kill007az/show_tracker_v2

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Watchlist MCP Host

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.

Architecture

┌──────────────────────────── 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)
       └──────────────────────────────────┘

Two-phase agent loop

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.

Tools

Data tools

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.

Render tools (Prefab widgets)

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.

Quiz answer-text contract

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.

Setup

# 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:8000

Agent 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']

Try it

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.

HTTP surface

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)

Layout

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

Notes / non-goals

  • One renderer per turn. The dynamic side reloads a single PrefabApp page 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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors