Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,18 @@ codex plugin add leaf@leaf

### Experimental Codex MCP App

The Codex plugin registers a bundled, local MCP server. This path is included so
we can test an embedded Leaf review in installed Codex builds; the experiment will
decide whether Leaf removes it, keeps the compact surface, or builds on it. It is
not a commitment to replace the browser runtime.

On Codex builds that render MCP Apps, the agent can attach the compact review
directly to the task. The page and event log remain ordinary local files, while
comments travel back as a follow-up turn. MCP App rendering is still gated in some
Codex builds, so Leaf falls back to its full browser page and detached delivery
loop there. The browser remains the full-fidelity surface for widget actions such
as dragging and choosing.
The Codex plugin registers one bundled local MCP server. On builds that render
[MCP Apps](https://github.com/modelcontextprotocol/ext-apps), the agent can attach
the complete Leaf interface directly to the task: comments, package widgets,
version travel, and the ordinary event path all run unchanged. One ephemeral
loopback origin serves every page opened by that MCP process under a separate
random capability path. It writes no service state and disappears with the MCP
session; the page directory and `comments.jsonl` remain the durable record.

The embedded route is experimental and local-host-only. A comments-only authored
snapshot remains available as an explicit fallback, and builds that do not render
the app use the normal full browser page. In every case the detached Codex adapter,
not an MCP host message, carries durable feedback into the next task turn.

No config or account is required. It needs
[`uv`](https://docs.astral.sh/uv/) and
Expand All @@ -64,6 +65,15 @@ Then ask the agent for a page. The explicit skill is `/leaf [topic]` in Claude C
and `$leaf [topic]` in Codex; with no argument it presents whatever the session is
currently about.

To expose the same resources from a checkout to another local MCP Apps host, run:

```sh
bin/leaf mcp
```

The model-visible `leaf_present` tool takes an initialized page's absolute
directory. `leaf_present_snapshot` selects the smaller fallback explicitly.

## Packages

A package carries a reusable theme, widget, browser module, data contract, or role
Expand Down
59 changes: 43 additions & 16 deletions notes/comparisons.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,22 +670,49 @@ finding the passage again, so leaf writes its own — unique-context confirmatio
detachment rather than a fallback to ordinals — and a shared field name would
advertise a matcher that isn't shared.

The door worth watching is none of those, and it is not a format. `@json-render/mcp`
serves a spec as an MCP App — the server returns a UI resource, the host renders it in a
sandboxed iframe, and `callServerTool` carries the press back — so one catalog reaches
Claude, ChatGPT, Cursor, VS Code, Goose and Postman without a hook written for any of them.
That is leaf's reach problem solved by the host instead of by the notation. What it does
not carry is the page: an MCP App is an iframe inside one chat message, in the host's
window, so there are no versions, no directory, and no reader who closed the conversation
and came back to it the next day. MCP Apps is already on this note's list of what a fuller
one would reach, and this is the angle a fuller entry would have to take: not another way
to describe a UI, but the one route by which a page could reach a host that has never
heard of it. What would change the answer is named in the extension's own deferrals: app
registrations must not outlive a session, a closed app's tool calls must error, and state
persistence and external URLs are put off to future extensions — so an app there today is
as mortal as the chat message holding it. External URLs or persistence landing, or a
terminal host shipping an Apps surface, is the signal to stop watching and prototype; even
then the route adds new hosts rather than retiring the two hooked ones.
The door worth opening is none of those, and it is not a format. [MCP Apps][mcp-apps] —
the official extension informed by the earlier community MCP-UI — lets a server return a
`ui://` resource, the host render it in a sandboxed iframe, and `callServerTool` carry a
press back. One server can therefore reach Claude, ChatGPT, VS Code and Goose without a
hook written for each. That is leaf's reach problem solved by the host instead of by a new
notation.

A first probe against the extension's reference host found that the iframe's lifetime
need not be the page's. An app-only `leaf_post_event` tool handed the same browser-shaped
event to leaf's existing append boundary, which wrote the page's real log; after host
teardown, reopening reconstructed the settled choice from that log. The stable extension
has no iframe-registered tools: this is an app-only server tool, distinct from the
app-provided tools in the [later draft][mcp-apps-draft]. Neither teardown binds the server
tools or page directory behind the view. That makes a server tool the compact surface's
natural return path. A direct app fetch also reached loopback through declared
`connectDomains`, but leaf correctly denied cross-origin API reads, so the compact surface
does not depend on CORS.

The larger experiment overturned the premise that only the compact surface belongs here.
The complete `ui://` app declared a loopback `frameDomains` origin and framed leaf's
ordinary browser page: the same document, vendored runtime, open vocabulary, comment
layer, versions, state stream and `/api/event` door. The reference host allowed the frame.
The remaining boundary was authentication, not interface: the normal `SameSite=Strict`
cookie is withheld in a third-party embed, while a secure partitioned cookie on the
process-scoped MCP server admitted assets and event writes without weakening ordinary
servers. The real page then measured 1060×332 inline and 1068×806 fullscreen with no
horizontal overflow, zero Axe violations, five representative widgets, a working return
control, and a keyboard choice appended to leaf's actual log. A second run authored a
normal passage-anchored comment and traveled v2 → v1 → v2; the immutable URLs remained
authorized and the same highlight resolved in both documents. MCP Apps can therefore
carry the whole local Leaf interface; the compact single-choice ask is an optional
below-the-fold optimization, not the supported-product boundary.

What stays mortal is the presentation, its loopback address, and its place in the
conversation. The browser must share the machine running the stdio server; a cloud host's
`localhost` is somewhere else. `ui/message` was accepted by the reference host, but
whether it starts a turn remains host policy; VS Code explicitly fills the chat input
without sending it. The route adds local GUI hosts and a disposable inline home for the
full page. It does not replace the page directory, durable browser deployment, wake
mechanism, or the two existing host hooks.

[mcp-apps]: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
[mcp-apps-draft]: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx

## When leaf is the wrong choice

Expand Down
41 changes: 41 additions & 0 deletions notes/mcp-apps/PROJECT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## Research Questions

**Primary**: Can Leaf add MCP Apps as a delivery surface without adding a second interface or state authority?

**Secondary**:

- What is the smallest server-tool boundary that preserves the page directory and append-only log as Leaf's durable record?
- Can one current, option-shaped reader ask degrade into a useful disposable inline surface?
- Which host capabilities are prerequisites, conveniences, or policy-dependent enhancements?

## Current Status

### Latest Results: experiment 30

The production candidate passed in the official reference host: one exact CSP
origin served the canonical page at a queryless random capability path, reached
Leaf's presentation gate, appended a keyboard option action through the ordinary
event endpoint, negotiated fullscreen, and produced no Leaf-origin console
failure. This removes the earlier wildcard CSP, per-page server, query token,
partitioned cookie, and hard-coded single-choice compact interface.

### Current Experiment: none

**Status**: Local reference-host candidate complete
**Purpose**: The combined registered server now covers the complete canonical
page, an explicit authored-snapshot fallback, and exact-origin capability routing.

## Next Steps

1. Probe the installed Codex host and record whether its private-network policy
accepts the exact localhost frame origin.
2. Keep the detached Codex adapter as the sole durable wake and acknowledgement
carrier; no remaining implementation decision depends on `ui/message` policy.
3. If accessibility parity becomes the next question, compare Axe before and
after the option action to isolate experiment 30's moderate `region` result.

## Reference

- Reproduce experiment 30: `bash notes/mcp-apps/experiments/30/commands.sh`
- Read its result: `cat notes/mcp-apps/experiments/30/results/reference-host.json`
- Inspect the current project: `sed -n '1,240p' notes/mcp-apps/PROJECT.md`
34 changes: 34 additions & 0 deletions notes/mcp-apps/experiments/1/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Experiment 1: Repository-owned compact ask

## Purpose

Test whether the successful transport probe can become a narrow Leaf primitive without introducing a new state model. The slice should present one current `lf-options` ask, submit the ordinary declared action through Leaf's existing event endpoint, and reconstruct the settled result from the log after reopening.

**Changes from the pre-project feasibility probe:**

- Move the server boundary from ignored probe code into Leaf's Python package.
- Replace probe-specific state with a declaration-derived compact-ask projection.
- Replace direct loopback fetches with app-only MCP server tools.
- Build the UI resource reproducibly from tracked source and a pinned MCP Apps SDK.

**Configuration:**

- Transport: stdio MCP server
- UI: disposable `ui://` HTML resource
- Return path: `callServerTool` → Leaf `EventEndpoint`
- Supported ask in this experiment: one current page-scoped `lf-options` decision
- Durable authority: authored revision plus `comments.jsonl`
- Reference host: ext-apps commit `10195ad91851502134930e9b80ec2c04e277a720`
- Authored fixture: `examples/design-decision.html`

**Expected outcomes:**

- If the app lists exactly one current ask and posts a schema-valid action, the boundary is narrow enough to productize experimentally.
- If teardown and a fresh read reconstruct the settled choice from `comments.jsonl`, no second state authority was introduced.
- If unsupported or ambiguous decisions return an explicit fallback instead of guessed controls, the compact mode can remain deliberately partial.
- If the bundled app fits at 420×360 without horizontal overflow and keeps keyboard/focus behavior, the disposable inline surface is viable for small asks.
- If the app requires loopback network access, the design has accidentally retained the probe's host-specific dependency and should be revised.

## Findings

The seven focused repository tests passed and the pinned official reference host built. The attempt then stopped before either service launched because `commands.sh` redirected their logs into a `results/` directory it had not created. It produced no browser or host-policy evidence; experiment 2 repeats the unchanged subject with that launch-path error fixed.
50 changes: 50 additions & 0 deletions notes/mcp-apps/experiments/1/commands.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/bin/bash
set -e
repo="$(git rev-parse --show-toplevel)"
cd "$repo"

results="$repo/notes/mcp-apps/experiments/1/results"
run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-1.XXXXXX")"
reference="$repo/.tmp/ext-apps-10195ad9"
server_pid=""
host_pid=""

cleanup() {
if [ -n "$host_pid" ]; then kill "$host_pid" 2>/dev/null || true; fi
if [ -n "$server_pid" ]; then kill "$server_pid" 2>/dev/null || true; fi
}
trap cleanup EXIT

uv run pytest -n0 tests/test_mcp_app.py

./bin/leaf page init "$run_dir/page"
cp examples/design-decision.html "$run_dir/page/index.html"
./bin/leaf version check "$run_dir/page"

if [ ! -d "$reference/.git" ]; then
git clone https://github.com/modelcontextprotocol/ext-apps.git "$reference"
git -C "$reference" checkout 10195ad91851502134930e9b80ec2c04e277a720
npm --prefix "$reference" ci --silent
fi
test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720"
npm --prefix "$reference" run build --workspace @modelcontextprotocol/ext-apps-basic-host >/dev/null

uv run python notes/mcp-apps/experiments/1/serve.py >"$results/mcp-server.log" 2>&1 &
server_pid=$!
(
cd "$reference/examples/basic-host"
exec env SERVERS='["http://localhost:3001/mcp"]' ../../node_modules/.bin/tsx serve.ts
) >"$results/reference-host.log" 2>&1 &
host_pid=$!

for _ in {1..60}; do
if curl --silent --fail http://localhost:8080/api/servers >/dev/null; then break; fi
sleep 0.5
done
curl --silent --fail http://localhost:8080/api/servers >/dev/null

uv run python notes/mcp-apps/experiments/1/observe.py "$run_dir/page" \
>"$results/reference-host.json"

# Open the generated compact-app screenshot:
# open notes/mcp-apps/experiments/1/results/compact-ask-420x360.png
131 changes: 131 additions & 0 deletions notes/mcp-apps/experiments/1/observe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Drive the official reference host through append, teardown, and reopen."""

from __future__ import annotations

import argparse
import json
from pathlib import Path

from leaf.event_log import read_events
from playwright.sync_api import expect, sync_playwright

HOST = "http://localhost:8080/?tool=leaf_open_page"
HERE = Path(__file__).parent


def app_frame(page):
expect(page.locator("iframe")).to_have_count(1)
outer = page.locator("iframe")
outer.evaluate(
"""node => {
node.style.setProperty('width', '420px', 'important');
node.style.setProperty('height', '360px', 'important');
}"""
)
sandbox = page.frame_locator("iframe")
expect(sandbox.locator("iframe")).to_have_count(1)
sandbox.locator("iframe").evaluate(
"""node => {
node.style.setProperty('width', '100%', 'important');
node.style.setProperty('height', '100%', 'important');
}"""
)
frame = sandbox.frame_locator("iframe")
expect(frame.locator("#sequence")).to_contain_text("event ")
return outer, frame


def measure(frame):
return frame.locator("html").evaluate(
"""() => ({
viewport: {width: innerWidth, height: innerHeight},
client: {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
},
scroll: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
question: document.querySelector('h1')?.textContent,
options: [...document.querySelectorAll('[data-option]')].map((node) => node.textContent),
focused: document.activeElement?.textContent,
})"""
)


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("page", type=Path)
args = parser.parse_args()
page_dir = args.page.resolve()

with sync_playwright() as playwright:
browser = playwright.chromium.launch(channel="chrome", headless=True)
page = browser.new_page(viewport={"width": 720, "height": 920})
console = []
page.on(
"console",
lambda message: (
console.append({"type": message.type, "text": message.text})
if message.type in {"error", "warning"}
else None
),
)
page.goto(HOST)
page.locator("textarea").fill(json.dumps({"page": str(page_dir)}))
page.get_by_role("button", name="Call Tool").click()
outer, frame = app_frame(page)
expect(
frame.get_by_role("heading", name="Where should a session live?")
).to_be_visible()
expect(frame.locator("[data-option]")).to_have_count(3)

option = frame.get_by_role("button", name="Redis, cookie fallback")
option.focus()
initial = measure(frame)
screenshot = HERE / "results/compact-ask-420x360.png"
outer.screenshot(path=screenshot)

option.press("Enter")
expect(
frame.get_by_role("heading", name="Nothing waiting here")
).to_be_visible()
after_choice = measure(frame)
event = read_events(page_dir)[-1]

page.get_by_title("Close").click()
expect(page.locator("iframe")).to_have_count(0)
page.get_by_role("button", name="Call Tool").click()
_, frame = app_frame(page)
expect(
frame.get_by_role("heading", name="Nothing waiting here")
).to_be_visible()
reopened = measure(frame)

result = {
"reference_host": {
"repo": "https://github.com/modelcontextprotocol/ext-apps",
"commit": "10195ad91851502134930e9b80ec2c04e277a720",
},
"initial": initial,
"horizontal_overflow": initial["scroll"]["width"]
> initial["client"]["width"],
"keyboard_choice": {
"widget": event["widget"],
"action": event["action"],
"detail": event["detail"],
"seq": event["seq"],
},
"after_choice": after_choice,
"after_teardown_and_reopen": reopened,
"console": console,
"screenshot": str(screenshot),
}
print(json.dumps(result, indent=2, ensure_ascii=False))
browser.close()


if __name__ == "__main__":
main()
Loading
Loading