# Plan 17-01: File-Based Widget System and Hot Reload

```yaml
wave: 1
depends_on: [16-02]
requirements: [DEVX-01, DEVX-04]
files_modified:
  - crates/mcp-preview/src/server.rs
  - crates/mcp-preview/src/handlers/api.rs
  - crates/mcp-preview/src/lib.rs
  - src/server/mcp_apps/widget_fs.rs
  - src/server/mcp_apps/mod.rs
  - examples/mcp-apps-chess/src/main.rs
  - examples/mcp-apps-chess/widgets/board.html
  - examples/mcp-apps-map/src/main.rs
  - examples/mcp-apps-map/widgets/map.html
autonomous: true
```

## Objective

Implement file-based widget authoring so widget HTML lives in a `widgets/` directory separate from Rust source, with each `.html` file automatically discovered as a UI resource. The preview server reads widget files from disk on every request so browser refresh shows the latest HTML without server restart.

## Context

- @crates/mcp-preview/src/server.rs — Preview server (needs widgets_dir config + disk reads)
- @crates/mcp-preview/src/handlers/api.rs — API handlers (resource read must serve from disk)
- @src/server/mcp_apps/ — MCP Apps module (needs widget filesystem discovery helper)
- @examples/mcp-apps-chess/src/main.rs — Chess example (inline widget HTML to extract)
- @examples/mcp-apps-map/src/main.rs — Map example (inline widget HTML to extract)

## must_haves

1. Widget HTML files live in `widgets/` directory, NOT inline in Rust source
2. Each `.html` file in `widgets/` automatically maps to a UI resource URI (`widgets/board.html` -> `ui://app/board`)
3. Browser refresh during `cargo pmcp preview` shows latest widget HTML without server restart
4. Existing chess and map examples migrate to file-based widgets and still work

## Tasks

<task id="17-01-A" type="auto">
<name>Widget filesystem discovery module</name>

<files>
- src/server/mcp_apps/widget_fs.rs (NEW)
- src/server/mcp_apps/mod.rs
</files>

<action>
Create `src/server/mcp_apps/widget_fs.rs` providing a `WidgetDir` helper struct:

1. `WidgetDir::new(path: impl Into<PathBuf>)` — stores the widgets directory path
2. `WidgetDir::discover(&self) -> Result<Vec<WidgetEntry>>` — scans the directory for `.html` files, returns a list of `WidgetEntry { filename, uri, path }` where:
   - `filename` = stem of the HTML file (e.g., "board")
   - `uri` = `format!("ui://app/{}", filename)`
   - `path` = absolute path to the file
3. `WidgetDir::read_widget(&self, name: &str) -> Result<String>` — reads the widget HTML from disk on each call (no caching — enables hot reload). If the file doesn't exist or has errors, return a styled HTML error page with filename and error details inline.
4. `WidgetDir::inject_bridge_script(html: &str, bridge_url: &str) -> String` — inserts a `<script src="{bridge_url}"></script>` tag before `</head>` (or at start of `<body>` if no `</head>`). This auto-injects `widget-runtime.js` so widget authors never write bridge boilerplate.

Add verbose tracing: `tracing::debug!("Reading widget file: {} ({} bytes)", path, content.len())` on each read.

Export `WidgetDir` and `WidgetEntry` from `src/server/mcp_apps/mod.rs`.

Convention per CONTEXT.md decisions:
- Filename maps directly to MCP resource URI: `widgets/board.html` -> `ui://app/board`
- Widgets are single self-contained HTML files
- Server auto-injects bridge script tag — zero boilerplate for widget authors
</action>

<verify>
  <automated>cargo build --features mcp-apps 2>&1 | head -20</automated>
</verify>

<done>
- `WidgetDir` struct exists with `discover()`, `read_widget()`, and `inject_bridge_script()` methods
- Module is exported from `mcp_apps`
- Reading a missing widget returns styled error HTML, not a panic
</done>
</task>

<task id="17-01-B" type="auto">
<name>Preview server hot-reload integration and example migration</name>

<files>
- crates/mcp-preview/src/server.rs
- crates/mcp-preview/src/handlers/api.rs
- crates/mcp-preview/src/lib.rs
- examples/mcp-apps-chess/widgets/board.html (NEW — extracted from inline string)
- examples/mcp-apps-chess/src/main.rs
- examples/mcp-apps-map/widgets/map.html (NEW — extracted from inline string)
- examples/mcp-apps-map/src/main.rs
</files>

<action>
**Part 1 — Preview server widget-from-disk path:**

Add an optional `widgets_dir: Option<PathBuf>` field to `PreviewConfig`. When set, the preview server:
- On `resources/list`: uses `WidgetDir::discover()` to enumerate available widgets from disk (in addition to proxy-fetched resources from the MCP server)
- On `resources/read` for `ui://app/*` URIs: uses `WidgetDir::read_widget()` to read from disk, then calls `WidgetDir::inject_bridge_script()` to auto-inject the bridge script URL before returning. This means every browser refresh reads fresh HTML from disk — hot reload without file watchers.
- When a widget file has a syntax error or is missing, show a styled error message inline in the iframe with filename and error details (per CONTEXT.md decision).

Add the `--widgets-dir` CLI flag to the Preview command in `cargo-pmcp/src/main.rs` and thread it through to `PreviewConfig`.

**Part 2 — Migrate chess example:**

1. Create `examples/mcp-apps-chess/widgets/board.html` — extract the widget HTML that is currently inline in the chess example's `ResourceHandler` implementation (the string returned by `read_resource`). Remove any bridge script tags from the extracted HTML since the server will auto-inject them.
2. Update `examples/mcp-apps-chess/src/main.rs` to use `WidgetDir` for the resource handler instead of returning inline HTML strings. The `list_resources` should delegate to `WidgetDir::discover()` and `read_resource` should delegate to `WidgetDir::read_widget()` + bridge injection.

**Part 3 — Migrate map example:**

Same pattern as chess: extract inline HTML to `examples/mcp-apps-map/widgets/map.html`, update `main.rs` to use `WidgetDir`.

Both examples must compile and the preview server must serve their widgets from the `widgets/` directory.
</action>

<verify>
  <automated>cargo build --features mcp-apps -p mcp-preview 2>&1 | head -20 && cargo build -p mcp-apps-chess 2>&1 | head -5 && cargo build -p mcp-apps-map 2>&1 | head -5</automated>
</verify>

<done>
- Preview server reads widget HTML from disk on each request when `widgets_dir` is configured
- Chess example widget HTML lives in `examples/mcp-apps-chess/widgets/board.html`, not inline in Rust
- Map example widget HTML lives in `examples/mcp-apps-map/widgets/map.html`, not inline in Rust
- Both examples compile and use `WidgetDir` for resource handling
- Browser refresh shows latest widget HTML without restarting the server
</done>
</task>
