diff --git a/README.md b/README.md index 019f4e356..09658dc68 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/notes/comparisons.md b/notes/comparisons.md index 2d8d657d3..2f92c0b30 100644 --- a/notes/comparisons.md +++ b/notes/comparisons.md @@ -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 diff --git a/notes/mcp-apps/PROJECT.md b/notes/mcp-apps/PROJECT.md new file mode 100644 index 000000000..689eaa1ac --- /dev/null +++ b/notes/mcp-apps/PROJECT.md @@ -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` diff --git a/notes/mcp-apps/experiments/1/README.md b/notes/mcp-apps/experiments/1/README.md new file mode 100644 index 000000000..8f1560624 --- /dev/null +++ b/notes/mcp-apps/experiments/1/README.md @@ -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. diff --git a/notes/mcp-apps/experiments/1/commands.sh b/notes/mcp-apps/experiments/1/commands.sh new file mode 100755 index 000000000..33ec738c5 --- /dev/null +++ b/notes/mcp-apps/experiments/1/commands.sh @@ -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 diff --git a/notes/mcp-apps/experiments/1/observe.py b/notes/mcp-apps/experiments/1/observe.py new file mode 100755 index 000000000..3fc81fe12 --- /dev/null +++ b/notes/mcp-apps/experiments/1/observe.py @@ -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() diff --git a/notes/mcp-apps/experiments/1/output.txt b/notes/mcp-apps/experiments/1/output.txt new file mode 100644 index 000000000..fb02386e0 --- /dev/null +++ b/notes/mcp-apps/experiments/1/output.txt @@ -0,0 +1,50 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 7 items + +tests/test_mcp_app.py ....... [100%] + +============================== 7 passed in 2.00s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-1.zannj9/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column +Cloning into '/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9'... +Note: switching to '10195ad91851502134930e9b80ec2c04e277a720'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +HEAD is now at 10195ad9 docs: add Alpic Playground to supported clients (#729) +🔧 Generating Zod schemas from spec.types.ts... + +✅ Written: /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/src/generated/schema.ts +✅ Written: /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/src/generated/schema.test.ts +✅ Written: /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/src/generated/schema.json + +🎉 Schema generation complete! +(node:70106) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead. +(Use `node --trace-deprecation ...` to show where the warning was created) +src/generated/schema.json 54ms +src/generated/schema.test.ts 38ms +src/generated/schema.ts 26ms +🔧 Syncing code snippets from example files... + +✅ No files needed modification + +🎉 Snippet sync complete! +notes/mcp-apps/experiments/1/commands.sh: line 32: /Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/1/results/mcp-server.log: No such file or directory +notes/mcp-apps/experiments/1/commands.sh: line 37: /Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/1/results/reference-host.log: No such file or directory diff --git a/notes/mcp-apps/experiments/1/serve.py b/notes/mcp-apps/experiments/1/serve.py new file mode 100755 index 000000000..780e4f31d --- /dev/null +++ b/notes/mcp-apps/experiments/1/serve.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Expose Leaf's stdio MCP server over HTTP for the official reference host.""" + +import uvicorn +from leaf.mcp_server import make_mcp_server +from starlette.middleware.cors import CORSMiddleware + + +def main() -> None: + app = make_mcp_server().streamable_http_app(host="127.0.0.1") + app = CORSMiddleware( + app, + allow_origins=["http://localhost:8080"], + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Mcp-Session-Id"], + ) + uvicorn.run(app, host="127.0.0.1", port=3001, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/10/README.md b/notes/mcp-apps/experiments/10/README.md new file mode 100644 index 000000000..76ded6e8e --- /dev/null +++ b/notes/mcp-apps/experiments/10/README.md @@ -0,0 +1,37 @@ +# Experiment 10: Evidence-bearing compact ask + +## Purpose + +Repeat the official reference-host path after review exposed the option evidence, +made status messages visible, rejected invalid mutable source explicitly, and +corrected the open/read tools' side-effect annotations. + +**Changes from experiment 8:** + +- Each choice retains its short label and visibly includes the authored spoken + evidence that distinguishes it. +- The footer visibly reports recording, success, refresh, and errors. +- Run axe-core inside the app frame. +- Measure the internal content scroller separately from document overflow. + +**Configuration:** + +- Transport, fixture, reference-host commit, outer frame (420×360), and keyboard + action unchanged from the successful reference-host experiments. + +**Expected outcomes:** + +- All three option labels and all three non-empty evidence summaries are present. +- The document itself does not overflow; the bounded content region may scroll to + preserve complete evidence at narrow width. +- Keyboard choice appends the ordinary action and exposes a visible success status. +- Axe reports no violations in the app frame. + +## Findings + +The app loaded and the nine focused protocol tests passed, but the run stopped at +the new accessibility check. `base.app_frame` returns a `FrameLocator`, while +`axe-playwright-python` calls `evaluate` on the concrete object it receives; +`FrameLocator` has no such method. No product assertion failed and the keyboard +action was not reached. Experiment 11 changes only the axe target to the innermost +concrete Playwright `Frame`. diff --git a/notes/mcp-apps/experiments/10/commands.sh b/notes/mcp-apps/experiments/10/commands.sh new file mode 100755 index 000000000..5686b3505 --- /dev/null +++ b/notes/mcp-apps/experiments/10/commands.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/10/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-10.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/10/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/10/observe.py b/notes/mcp-apps/experiments/10/observe.py new file mode 100755 index 000000000..975f72281 --- /dev/null +++ b/notes/mcp-apps/experiments/10/observe.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Measure the reviewed, evidence-bearing compact surface.""" + +import json +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +from axe_playwright_python.sync_playwright import Axe +from leaf.event_log import read_events +from playwright.sync_api import expect, sync_playwright + +HERE = Path(__file__).parent +source = HERE.parent / "1/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_1_observe", source) +base = module_from_spec(spec) +spec.loader.exec_module(base) +base.HERE = HERE + + +def measure(frame): + return frame.locator("html").evaluate( + """() => { + const root = document.documentElement; + const content = document.querySelector('#content'); + return { + viewport: {width: innerWidth, height: innerHeight}, + document: { + client_width: root.clientWidth, + client_height: root.clientHeight, + scroll_width: root.scrollWidth, + scroll_height: root.scrollHeight, + }, + content: { + client_height: content.clientHeight, + scroll_height: content.scrollHeight, + }, + question: document.querySelector('h1')?.textContent, + labels: [...document.querySelectorAll('.option-label')].map( + (node) => node.textContent + ), + summary_lengths: [...document.querySelectorAll('.option-summary')].map( + (node) => node.textContent.length + ), + }; + }""" + ) + + +def main() -> None: + page_dir = Path(sys.argv[1]).resolve() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel="chrome", headless=True) + page = browser.new_page(viewport={"width": 720, "height": 920}) + page.set_default_timeout(15_000) + console = [] + page.on( + "console", + lambda message: ( + console.append( + { + "type": message.type, + "text": message.text, + "location": message.location, + } + ) + if message.type in {"error", "warning"} + else None + ), + ) + + page.goto(base.HOST) + tool_select = page.locator("select").nth(1) + expect(tool_select).to_have_value("leaf_open_page") + model_tools = tool_select.locator("option").all_text_contents() + tool_input = page.locator("textarea") + expect(tool_input).to_have_value("{}") + tool_input.fill(json.dumps({"page": str(page_dir)})) + page.get_by_role("button", name="Call Tool").click() + outer, frame = base.app_frame(page) + expect( + frame.get_by_role("heading", name="Where should a session live?") + ).to_be_visible() + expect(frame.locator(".option-summary")).to_have_count(3) + + option = frame.get_by_role("button", name="Redis, cookie fallback") + option.focus() + initial = measure(frame) + axe = Axe().run(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() + expect(frame.get_by_role("status")).to_have_text( + "Recorded Redis, cookie fallback" + ) + event = read_events(page_dir)[-1] + status_box = frame.get_by_role("status").bounding_box() + + print( + json.dumps( + { + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720", + }, + "model_visible_tools": model_tools, + "initial": initial, + "document_horizontal_overflow": ( + initial["document"]["scroll_width"] + > initial["document"]["client_width"] + ), + "document_vertical_overflow": ( + initial["document"]["scroll_height"] + > initial["document"]["client_height"] + ), + "content_scrolls": ( + initial["content"]["scroll_height"] + > initial["content"]["client_height"] + ), + "axe": { + "violations": axe.violations_count, + "snapshot": axe.generate_snapshot(), + }, + "keyboard_choice": { + "widget": event["widget"], + "action": event["action"], + "detail": event["detail"], + "seq": event["seq"], + }, + "visible_status_box": status_box, + "console": console, + "screenshot": str(screenshot), + }, + indent=2, + ensure_ascii=False, + ) + ) + browser.close() + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/10/output.txt b/notes/mcp-apps/experiments/10/output.txt new file mode 100644 index 000000000..b5c7a7360 --- /dev/null +++ b/notes/mcp-apps/experiments/10/output.txt @@ -0,0 +1,22 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 9 items + +tests/test_mcp_app.py ......... [100%] + +============================== 9 passed in 3.56s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-10.JTd3t9/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column +Traceback (most recent call last): + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/10/observe.py", line 144, in + main() + ~~~~^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/10/observe.py", line 87, in main + axe = Axe().run(frame) + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/axe_playwright_python/sync_playwright.py", line 32, in run + page.evaluate(self.axe_script) + ^^^^^^^^^^^^^ +AttributeError: 'FrameLocator' object has no attribute 'evaluate' diff --git a/notes/mcp-apps/experiments/10/results/mcp-server.log b/notes/mcp-apps/experiments/10/results/mcp-server.log new file mode 100644 index 000000000..acf34a488 --- /dev/null +++ b/notes/mcp-apps/experiments/10/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: e74f8376b1e445e09a2fe0b1e59a29d9 diff --git a/notes/mcp-apps/experiments/10/results/reference-host.json b/notes/mcp-apps/experiments/10/results/reference-host.json new file mode 100644 index 000000000..19765bd50 --- /dev/null +++ b/notes/mcp-apps/experiments/10/results/reference-host.json @@ -0,0 +1 @@ +null diff --git a/notes/mcp-apps/experiments/10/results/reference-host.log b/notes/mcp-apps/experiments/10/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/10/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/11/README.md b/notes/mcp-apps/experiments/11/README.md new file mode 100644 index 000000000..04ba876f5 --- /dev/null +++ b/notes/mcp-apps/experiments/11/README.md @@ -0,0 +1,31 @@ +# Experiment 11: Axe the concrete app frame + +## Purpose + +Complete experiment 10 after its accessibility probe confused the nested app's +`FrameLocator` with the concrete Playwright `Frame` that axe can evaluate. + +**Changes from experiment 10:** + +- Pass the innermost concrete frame to axe. Product code, fixture, dimensions, + and actions are unchanged. + +**Expected outcomes:** + +- The evidence, scrolling, visible status, keyboard append, console, and axe + readings all complete in one reference-host run. + +## Findings + +The measurement completed and found two CSS defects introduced by exposing the +evidence. The grid shell used `min-height: 100vh`, so its auto height grew to 610 +pixels instead of holding the 354-pixel app viewport; the intended content +scroller never engaged and the success status landed below the visible frame. +Axe also reported three serious color-contrast violations, one for each evidence +summary using the host's secondary text token on the option panel. + +The protocol and input path remained sound: all three evidence summaries were +present (303–471 characters), keyboard Enter appended the expected `choose`, and +the only console error was the reference host favicon. Experiment 12 changes +only the shell to a fixed viewport height and gives decision evidence the +primary text color. diff --git a/notes/mcp-apps/experiments/11/commands.sh b/notes/mcp-apps/experiments/11/commands.sh new file mode 100755 index 000000000..a9a59e29a --- /dev/null +++ b/notes/mcp-apps/experiments/11/commands.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/11/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-11.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/11/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/11/observe.py b/notes/mcp-apps/experiments/11/observe.py new file mode 100755 index 000000000..df3a7a4df --- /dev/null +++ b/notes/mcp-apps/experiments/11/observe.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Measure the reviewed surface and run axe in its concrete frame.""" + +import json +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +from axe_playwright_python.sync_playwright import Axe +from leaf.event_log import read_events +from playwright.sync_api import expect, sync_playwright + +HERE = Path(__file__).parent + + +def load(name: str, path: Path): + spec = spec_from_file_location(name, path) + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +base = load("mcp_apps_experiment_1_observe", HERE.parent / "1/observe.py") +previous = load("mcp_apps_experiment_10_observe", HERE.parent / "10/observe.py") +base.HERE = HERE + + +def main() -> None: + page_dir = Path(sys.argv[1]).resolve() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel="chrome", headless=True) + page = browser.new_page(viewport={"width": 720, "height": 920}) + page.set_default_timeout(15_000) + console = [] + page.on( + "console", + lambda message: ( + console.append( + { + "type": message.type, + "text": message.text, + "location": message.location, + } + ) + if message.type in {"error", "warning"} + else None + ), + ) + + page.goto(base.HOST) + tool_select = page.locator("select").nth(1) + expect(tool_select).to_have_value("leaf_open_page") + model_tools = tool_select.locator("option").all_text_contents() + tool_input = page.locator("textarea") + expect(tool_input).to_have_value("{}") + tool_input.fill(json.dumps({"page": str(page_dir)})) + page.get_by_role("button", name="Call Tool").click() + outer, frame = base.app_frame(page) + expect( + frame.get_by_role("heading", name="Where should a session live?") + ).to_be_visible() + expect(frame.locator(".option-summary")).to_have_count(3) + + option = frame.get_by_role("button", name="Redis, cookie fallback") + option.focus() + initial = previous.measure(frame) + axe = Axe().run(page.frames[-1]) + 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() + expect(frame.get_by_role("status")).to_have_text( + "Recorded Redis, cookie fallback" + ) + event = read_events(page_dir)[-1] + status_box = frame.get_by_role("status").bounding_box() + + print( + json.dumps( + { + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720", + }, + "model_visible_tools": model_tools, + "initial": initial, + "document_horizontal_overflow": ( + initial["document"]["scroll_width"] + > initial["document"]["client_width"] + ), + "document_vertical_overflow": ( + initial["document"]["scroll_height"] + > initial["document"]["client_height"] + ), + "content_scrolls": ( + initial["content"]["scroll_height"] + > initial["content"]["client_height"] + ), + "axe": { + "violations": axe.violations_count, + "snapshot": axe.generate_snapshot(), + }, + "keyboard_choice": { + "widget": event["widget"], + "action": event["action"], + "detail": event["detail"], + "seq": event["seq"], + }, + "visible_status_box": status_box, + "console": console, + "screenshot": str(screenshot), + }, + indent=2, + ensure_ascii=False, + ) + ) + browser.close() + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/11/output.txt b/notes/mcp-apps/experiments/11/output.txt new file mode 100644 index 000000000..431de8ac2 --- /dev/null +++ b/notes/mcp-apps/experiments/11/output.txt @@ -0,0 +1,12 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 9 items + +tests/test_mcp_app.py ......... [100%] + +============================== 9 passed in 6.37s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-11.OpqIP3/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column diff --git a/notes/mcp-apps/experiments/11/results/mcp-server.log b/notes/mcp-apps/experiments/11/results/mcp-server.log new file mode 100644 index 000000000..b0a0f8a15 --- /dev/null +++ b/notes/mcp-apps/experiments/11/results/mcp-server.log @@ -0,0 +1,3 @@ +StreamableHTTP session manager started +Created new transport with session ID: b90489f1561c4b219cae972f15ec000b +StreamableHTTP session manager shutting down diff --git a/notes/mcp-apps/experiments/11/results/reference-host.json b/notes/mcp-apps/experiments/11/results/reference-host.json new file mode 100644 index 000000000..656c2d200 --- /dev/null +++ b/notes/mcp-apps/experiments/11/results/reference-host.json @@ -0,0 +1,73 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "model_visible_tools": [ + "leaf_open_page" + ], + "initial": { + "viewport": { + "width": 414, + "height": 354 + }, + "document": { + "client_width": 414, + "client_height": 354, + "scroll_width": 414, + "scroll_height": 610 + }, + "content": { + "client_height": 526, + "scroll_height": 526 + }, + "question": "Where should a session live?", + "labels": [ + "Stateless JWT", + "Redis, cookie fallback", + "Postgres table" + ], + "summary_lengths": [ + 471, + 406, + 303 + ] + }, + "document_horizontal_overflow": false, + "document_vertical_overflow": true, + "content_scrolls": false, + "axe": { + "violations": 1, + "snapshot": "color-contrast (serious) : 3" + }, + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 1 + }, + "visible_status_box": { + "x": 134.015625, + "y": 743.5, + "width": 287.984375, + "height": 15 + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ], + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/11/results/compact-ask-420x360.png" +} diff --git a/notes/mcp-apps/experiments/11/results/reference-host.log b/notes/mcp-apps/experiments/11/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/11/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/12/README.md b/notes/mcp-apps/experiments/12/README.md new file mode 100644 index 000000000..c3adcb3b0 --- /dev/null +++ b/notes/mcp-apps/experiments/12/README.md @@ -0,0 +1,35 @@ +# Experiment 12: Contain and clarify evidence + +## Purpose + +Repeat experiment 11 after its measurements showed that evidence expanded the +document and used insufficient contrast on the host's option-panel color. + +**Changes from experiment 11:** + +- Fix the app shell at the iframe viewport height so the middle grid row owns + scrolling and the footer remains visible. +- Use the primary text color for decision evidence. + +**Expected outcomes:** + +- No document overflow; the content row scrolls vertically at 420×360. +- The visible footer status remains inside the 354-pixel app viewport. +- Axe reports no violations. +- Evidence and keyboard append behavior remain unchanged. + +## Findings + +The two CSS changes resolved both measured defects. In the 414×354 app viewport, +the document's client and scroll dimensions were identical, while the middle +content row held a 270-pixel viewport over 526 pixels of complete option content. +All three short labels and all three evidence summaries remained present. The +footer stayed in the fixed shell and exposed a real 15-pixel-high status box +after the choice. + +Axe reported zero violations. Keyboard Enter appended the same ordinary +`session-options` / `choose` / `opt-redis` event at sequence 1. The only console +error was again the official reference host's missing favicon. This is the first +run in which the evidence-bearing compact surface, containment, contrast, +visible feedback, accessibility scan, and durable return path all passed +together. diff --git a/notes/mcp-apps/experiments/12/commands.sh b/notes/mcp-apps/experiments/12/commands.sh new file mode 100755 index 000000000..b5fe95737 --- /dev/null +++ b/notes/mcp-apps/experiments/12/commands.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/12/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-12.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/12/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/12/observe.py b/notes/mcp-apps/experiments/12/observe.py new file mode 100755 index 000000000..cf0101f5c --- /dev/null +++ b/notes/mcp-apps/experiments/12/observe.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Repeat the evidence-bearing app after its two CSS corrections.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "11/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_11_observe", source) +experiment = module_from_spec(spec) +spec.loader.exec_module(experiment) +experiment.HERE = HERE +experiment.base.HERE = HERE + + +if __name__ == "__main__": + experiment.main() diff --git a/notes/mcp-apps/experiments/12/output.txt b/notes/mcp-apps/experiments/12/output.txt new file mode 100644 index 000000000..5175b27be --- /dev/null +++ b/notes/mcp-apps/experiments/12/output.txt @@ -0,0 +1,12 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 9 items + +tests/test_mcp_app.py ......... [100%] + +============================== 9 passed in 2.55s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-12.jPhlql/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column diff --git a/notes/mcp-apps/experiments/12/results/mcp-server.log b/notes/mcp-apps/experiments/12/results/mcp-server.log new file mode 100644 index 000000000..43ff546c3 --- /dev/null +++ b/notes/mcp-apps/experiments/12/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: 1d8af92fb7834a56ad6f9c95ac3833f3 diff --git a/notes/mcp-apps/experiments/12/results/reference-host.json b/notes/mcp-apps/experiments/12/results/reference-host.json new file mode 100644 index 000000000..e00551ca6 --- /dev/null +++ b/notes/mcp-apps/experiments/12/results/reference-host.json @@ -0,0 +1,73 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "model_visible_tools": [ + "leaf_open_page" + ], + "initial": { + "viewport": { + "width": 414, + "height": 354 + }, + "document": { + "client_width": 414, + "client_height": 354, + "scroll_width": 414, + "scroll_height": 354 + }, + "content": { + "client_height": 270, + "scroll_height": 526 + }, + "question": "Where should a session live?", + "labels": [ + "Stateless JWT", + "Redis, cookie fallback", + "Postgres table" + ], + "summary_lengths": [ + 471, + 406, + 303 + ] + }, + "document_horizontal_overflow": false, + "document_vertical_overflow": false, + "content_scrolls": true, + "axe": { + "violations": 0, + "snapshot": "" + }, + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 1 + }, + "visible_status_box": { + "x": 134.015625, + "y": 487.5, + "width": 287.984375, + "height": 15 + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ], + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/12/results/compact-ask-420x360.png" +} diff --git a/notes/mcp-apps/experiments/12/results/reference-host.log b/notes/mcp-apps/experiments/12/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/12/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/13/README.md b/notes/mcp-apps/experiments/13/README.md new file mode 100644 index 000000000..dc64866b8 --- /dev/null +++ b/notes/mcp-apps/experiments/13/README.md @@ -0,0 +1,30 @@ +# Experiment 13: Final generated bundle + +## Purpose + +Repeat experiment 12 against the exact bundle left by the formatter, two +byte-identical vendor builds, parser cleanup, and full-suite review. + +**Changes from experiment 12:** + +- Product behavior is unchanged; record the final bundle's SHA-256 beside the + same reference-host, axe, layout, evidence, and keyboard readings. + +**Expected outcomes:** + +- Experiment 12's results repeat against the exact bytes being handed over. +- The recorded hash matches the twice-reproduced vendor output. + +## Findings + +The final generated bundle passed the same complete reference-host measurement. +Its SHA-256 was +`bd8134f2622641ae37512dcf7108b4ccccb5bed03bd842f89800e794272effd7`, +matching both reproducibility builds. At 414×354 the document had no overflow, +the evidence row scrolled internally, all three complete summaries remained, +axe reported zero violations, and keyboard Enter appended the ordinary Redis +choice at sequence 1. The only console error remained the reference host's +missing favicon. + +This run exercised the exact HTML bytes left in the worktree after formatting, +review fixes, and the 740-test everyday suite. diff --git a/notes/mcp-apps/experiments/13/commands.sh b/notes/mcp-apps/experiments/13/commands.sh new file mode 100755 index 000000000..1dc2385be --- /dev/null +++ b/notes/mcp-apps/experiments/13/commands.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/13/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-13.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" +shasum -a 256 skills/leaf/assets/vendor/mcp-compact-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/13/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/13/observe.py b/notes/mcp-apps/experiments/13/observe.py new file mode 100755 index 000000000..617d26e61 --- /dev/null +++ b/notes/mcp-apps/experiments/13/observe.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Repeat the final reference-host checks against the handed-over bundle.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "11/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_11_observe", source) +experiment = module_from_spec(spec) +spec.loader.exec_module(experiment) +experiment.HERE = HERE +experiment.base.HERE = HERE + + +if __name__ == "__main__": + experiment.main() diff --git a/notes/mcp-apps/experiments/13/output.txt b/notes/mcp-apps/experiments/13/output.txt new file mode 100644 index 000000000..08dcfadaa --- /dev/null +++ b/notes/mcp-apps/experiments/13/output.txt @@ -0,0 +1,12 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 10 items + +tests/test_mcp_app.py .......... [100%] + +============================= 10 passed in 13.44s ============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-13.l66mxL/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column diff --git a/notes/mcp-apps/experiments/13/results/bundle.sha256 b/notes/mcp-apps/experiments/13/results/bundle.sha256 new file mode 100644 index 000000000..773677870 --- /dev/null +++ b/notes/mcp-apps/experiments/13/results/bundle.sha256 @@ -0,0 +1 @@ +bd8134f2622641ae37512dcf7108b4ccccb5bed03bd842f89800e794272effd7 skills/leaf/assets/vendor/mcp-compact-app.html diff --git a/notes/mcp-apps/experiments/13/results/mcp-server.log b/notes/mcp-apps/experiments/13/results/mcp-server.log new file mode 100644 index 000000000..d9a8413ba --- /dev/null +++ b/notes/mcp-apps/experiments/13/results/mcp-server.log @@ -0,0 +1,3 @@ +StreamableHTTP session manager started +Created new transport with session ID: 0d4f0680b3ff403ba7198b9c9983c2e8 +StreamableHTTP session manager shutting down diff --git a/notes/mcp-apps/experiments/13/results/reference-host.json b/notes/mcp-apps/experiments/13/results/reference-host.json new file mode 100644 index 000000000..0d8bc1c0b --- /dev/null +++ b/notes/mcp-apps/experiments/13/results/reference-host.json @@ -0,0 +1,73 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "model_visible_tools": [ + "leaf_open_page" + ], + "initial": { + "viewport": { + "width": 414, + "height": 354 + }, + "document": { + "client_width": 414, + "client_height": 354, + "scroll_width": 414, + "scroll_height": 354 + }, + "content": { + "client_height": 270, + "scroll_height": 526 + }, + "question": "Where should a session live?", + "labels": [ + "Stateless JWT", + "Redis, cookie fallback", + "Postgres table" + ], + "summary_lengths": [ + 471, + 406, + 303 + ] + }, + "document_horizontal_overflow": false, + "document_vertical_overflow": false, + "content_scrolls": true, + "axe": { + "violations": 0, + "snapshot": "" + }, + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 1 + }, + "visible_status_box": { + "x": 134.015625, + "y": 487.5, + "width": 287.984375, + "height": 15 + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ], + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/13/results/compact-ask-420x360.png" +} diff --git a/notes/mcp-apps/experiments/13/results/reference-host.log b/notes/mcp-apps/experiments/13/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/13/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/14/README.md b/notes/mcp-apps/experiments/14/README.md new file mode 100644 index 000000000..d6f1074b7 --- /dev/null +++ b/notes/mcp-apps/experiments/14/README.md @@ -0,0 +1,42 @@ +# Experiment 14: The complete Leaf page in an MCP App + +## Purpose + +Test whether an MCP App can carry Leaf's existing browser interface without +reducing the document to a compact projection. The app embeds the ordinary Leaf +page served from a process-scoped loopback server, so the authored document, +runtime, widgets, comments, versions, and event endpoint remain unchanged. + +**Changes from experiment 13:** + +- Replace the compact ask renderer with a full-page MCP resource. +- Let the MCP server own an ephemeral loopback page server for the lifetime of + the MCP process. +- Declare that loopback origin through the app resource's `frameDomains` CSP. +- Offer the protocol's negotiated fullscreen mode while retaining an inline + preview. + +**Expected outcomes:** + +- If the reference host honors the declared loopback frame origin, the real + Leaf page reaches all three runtime readiness stamps inside the MCP App. +- A keyboard choice made inside the embedded page appends the ordinary Leaf + event and repaints through the existing runtime. +- Fullscreen gives the page enough room for its normal layout; the inline frame + remains usable as a constrained preview. +- If the nested page is blocked, the browser console and frame state identify + whether CSP, mixed-content policy, sandbox inheritance, or loopback routing is + the boundary. + +## Findings + +The run did not reach the frame-policy question. The official host called +`leaf_open_page`, but the MCP server rejected its `page` argument during tool +validation. The app resource still rendered and created `#leaf-page`; because +the originating tool result carried no page state, that iframe remained the +empty `about:blank` document and never acquired Leaf's readiness stamps. + +The focused suite and the same tool over a real stdio MCP session both accepted +the page string. Experiment 15 must capture the reference host's concrete tool +arguments and returned error before changing CSP or page framing. This failed +run provides no evidence for or against loopback `frameDomains`. diff --git a/notes/mcp-apps/experiments/14/commands.sh b/notes/mcp-apps/experiments/14/commands.sh new file mode 100644 index 000000000..f302ad328 --- /dev/null +++ b/notes/mcp-apps/experiments/14/commands.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/14/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-14.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" +shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/14/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/14/observe.py b/notes/mcp-apps/experiments/14/observe.py new file mode 100755 index 000000000..5e4c3830a --- /dev/null +++ b/notes/mcp-apps/experiments/14/observe.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Measure the complete Leaf page inside the official MCP Apps host.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from axe_playwright_python.sync_playwright import Axe +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") + sandbox = page.frame_locator("iframe") + expect(sandbox.locator("iframe")).to_have_count(1) + app = sandbox.frame_locator("iframe") + expect(app.locator("#leaf-page")).to_have_count(1) + leaf = app.frame_locator("#leaf-page") + expect(leaf.locator("body")).to_have_attribute("data-lf-presented", "1") + return outer, app, leaf + + +def measure(frame): + return frame.locator("html").evaluate( + """() => ({ + viewport: {width: innerWidth, height: innerHeight}, + document: { + client_width: document.documentElement.clientWidth, + client_height: document.documentElement.clientHeight, + scroll_width: document.documentElement.scrollWidth, + scroll_height: document.documentElement.scrollHeight, + }, + stamps: { + upgraded: document.body.dataset.lfUpgraded, + applied: document.body.dataset.lfApplied, + presented: document.body.dataset.lfPresented, + }, + headings: [...document.querySelectorAll('h1, h2')].map( + (node) => node.textContent.trim() + ), + widgets: [...document.querySelectorAll('lf-options, lf-compare, lf-gloss')] + .map((node) => node.localName), + })""" + ) + + +def main() -> None: + page_dir = Path(sys.argv[1]).resolve() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel="chrome", headless=True) + page = browser.new_page(viewport={"width": 1100, "height": 900}) + page.set_default_timeout(20_000) + console = [] + page.on( + "console", + lambda message: ( + console.append( + { + "type": message.type, + "text": message.text, + "location": message.location, + } + ) + 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, app, leaf = app_frame(page) + expect(leaf.get_by_role("heading", name="Where sessions live")).to_be_visible() + + inline = measure(leaf) + inline_shot = HERE / "results/full-page-inline.png" + outer.screenshot(path=inline_shot) + + app.get_by_role("button", name="Full screen").click() + expect(app.get_by_role("button", name="Return inline")).to_be_visible() + fullscreen = measure(leaf) + axe = Axe().run(leaf) + fullscreen_shot = HERE / "results/full-page-fullscreen.png" + page.screenshot(path=fullscreen_shot, full_page=True) + + option = leaf.locator("#opt-redis").get_by_role("checkbox") + option.focus() + option.press("Space") + expect(leaf.locator("#opt-redis")).to_have_attribute("chosen", "") + event = read_events(page_dir)[-1] + + print( + json.dumps( + { + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720", + }, + "inline": inline, + "fullscreen": fullscreen, + "axe": { + "violations": axe.violations_count, + "snapshot": axe.generate_snapshot(), + }, + "keyboard_choice": { + "widget": event["widget"], + "action": event["action"], + "detail": event["detail"], + "seq": event["seq"], + }, + "console": console, + "screenshots": { + "inline": str(inline_shot), + "fullscreen": str(fullscreen_shot), + }, + }, + indent=2, + ensure_ascii=False, + ) + ) + browser.close() + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/14/results/bundle.sha256 b/notes/mcp-apps/experiments/14/results/bundle.sha256 new file mode 100644 index 000000000..a740c9bf3 --- /dev/null +++ b/notes/mcp-apps/experiments/14/results/bundle.sha256 @@ -0,0 +1 @@ +b222706fb57665b1bb7018bdda49afa3f480aacc805819c0c3bad623372aec76 skills/leaf/assets/vendor/mcp-page-app.html diff --git a/notes/mcp-apps/experiments/14/results/mcp-server.log b/notes/mcp-apps/experiments/14/results/mcp-server.log new file mode 100644 index 000000000..f50ba1188 --- /dev/null +++ b/notes/mcp-apps/experiments/14/results/mcp-server.log @@ -0,0 +1,3 @@ +StreamableHTTP session manager started +Created new transport with session ID: 9c07dc36993c492a9e7cf34639c5225b +Tool 'leaf_open_page' rejected arguments: ['page'] diff --git a/notes/mcp-apps/experiments/14/results/reference-host.json b/notes/mcp-apps/experiments/14/results/reference-host.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/notes/mcp-apps/experiments/14/results/reference-host.json @@ -0,0 +1 @@ +{} diff --git a/notes/mcp-apps/experiments/14/results/reference-host.log b/notes/mcp-apps/experiments/14/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/14/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/15/README.md b/notes/mcp-apps/experiments/15/README.md new file mode 100644 index 000000000..5a04b867d --- /dev/null +++ b/notes/mcp-apps/experiments/15/README.md @@ -0,0 +1,34 @@ +# Experiment 15: Reference-host full-page retry + +## Purpose + +Capture and correct the tool-call discrepancy that stopped experiment 14, then +repeat the same complete-page probe. + +**Changes from experiment 14:** + +- Record the reference host's selected tool, input text, and rendered call error + before waiting for the app's nested page. +- Change no frame CSP, server lifetime, or app layout until the rejected tool + argument is explained. + +**Expected outcomes:** + +- If the harness targeted the wrong control or tool, the captured host state + identifies it and the corrected call reaches the page resource. +- If the host serializes the page input differently from stdio, the returned + validation error identifies the concrete mismatch. +- Once the tool call succeeds, the original experiment 14 frame and interaction + expectations run unchanged. + +## Findings + +The diagnostic run confirmed experiment 14's rejected `page` was a harness +race: the earlier script wrote the input while the reference host was still +initializing the tool, and `handleServerSelect` subsequently restored `{}`. + +This run stopped at the next initialization boundary. Playwright began waiting +on the empty tool `\\n - unexpected value \"\"\\n\\nAria snapshot:\\n- button \"🌙\"\\n- text: Server\\n- combobox \"Server\":\\n - option \"Leaf\" [selected]\\n- text: Tool\\n- combobox \"Tool\":\\n - option \"leaf_open_compact_ask\"\\n - option \"leaf_open_page\" [selected]\\n- text: Input\\n- textbox \"Input\": \"{}\"\\n- button \"Call Tool\"')", + "url": "http://localhost:8080/?tool=leaf_open_page", + "body": "🌙\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool", + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/15/results/failure.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/15/results/reference-host.log b/notes/mcp-apps/experiments/15/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/15/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/16/README.md b/notes/mcp-apps/experiments/16/README.md new file mode 100644 index 000000000..3abb612f2 --- /dev/null +++ b/notes/mcp-apps/experiments/16/README.md @@ -0,0 +1,30 @@ +# Experiment 16: Hydrated reference-host full-page probe + +## Purpose + +Wait for the reference host's asynchronous tool controls to become stable, +then run the original complete-page frame and interaction measurement. + +**Changes from experiment 15:** + +- Wait for both visible tool options before selecting `leaf_open_page`. +- Assert the stable `{}` default before writing the page argument. +- Keep the app resource, frame CSP, Leaf server, fixture, and browser readings + unchanged. + +**Expected outcomes:** + +- The rendered Tool Input panel preserves the exact page directory submitted by + the harness. +- The nested loopback page either reaches Leaf's readiness stamps or leaves a + concrete frame/CSP failure in the preserved diagnostics. +- On success, inline and fullscreen dimensions, accessibility, console output, + and a keyboard-authored Leaf event establish the first full-interface result. + +## Findings + +The host shell appeared immediately, but the MCP tool options did not hydrate +within the 20-second barrier. The failure snapshot taken immediately afterward +showed both options rendered, so the selected tool was still never called and +the loopback frame remained untested. Experiment 17 raises only this discovery +barrier to 60 seconds. diff --git a/notes/mcp-apps/experiments/16/commands.sh b/notes/mcp-apps/experiments/16/commands.sh new file mode 100644 index 000000000..9b94acac3 --- /dev/null +++ b/notes/mcp-apps/experiments/16/commands.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/16/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-16.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" +shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/16/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/16/observe.py b/notes/mcp-apps/experiments/16/observe.py new file mode 100755 index 000000000..2cb9a5673 --- /dev/null +++ b/notes/mcp-apps/experiments/16/observe.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Measure a complete Leaf page after tool controls have hydrated.""" + +from __future__ import annotations + +import json +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +from axe_playwright_python.sync_playwright import Axe +from leaf.event_log import read_events +from playwright.sync_api import expect, sync_playwright + +HERE = Path(__file__).parent +source = HERE.parent / "15/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_15_observe", source) +base = module_from_spec(spec) +spec.loader.exec_module(base) + + +def main( + tool_timeout: int = 20_000, + wait_for_options: bool = True, + use_dom_gate: bool = False, + stable_panels: bool = False, + body_call_record: bool = False, + tolerate_mode_control: bool = False, + exercise_comments_versions: bool = False, +) -> None: + page_dir = Path(sys.argv[1]).resolve() + result = { + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720", + } + } + failed = False + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel="chrome", headless=True) + page = browser.new_page(viewport={"width": 1100, "height": 900}) + page.set_default_timeout(20_000) + console = [] + page.on( + "console", + lambda message: ( + console.append( + { + "type": message.type, + "text": message.text, + "location": message.location, + } + ) + if message.type in {"error", "warning"} + else None + ), + ) + + try: + page.goto(base.HOST) + if use_dom_gate: + page.wait_for_function( + """() => document.querySelectorAll('select')[1]?.value + === 'leaf_open_page'""", + timeout=tool_timeout, + ) + tool_select = page.locator("select").nth(1) + else: + tool_select = page.get_by_label("Tool", exact=True) + if wait_for_options: + expect(tool_select.locator("option")).to_have_count( + 2, timeout=tool_timeout + ) + expect(tool_select).to_have_value( + "leaf_open_page", timeout=tool_timeout + ) + tool_input = ( + page.locator("textarea") + if use_dom_gate + else page.get_by_label("Input", exact=True) + ) + expect(tool_input).to_have_value("{}") + + payload = json.dumps({"page": str(page_dir)}) + tool_input.fill(payload) + expect(tool_input).to_have_value(payload) + result["invocation"] = { + "selected_tool": tool_select.input_value(), + "initial_input": "{}", + "submitted_input": tool_input.input_value(), + } + + page.get_by_role("button", name="Call Tool").click() + if body_call_record: + page.wait_for_function( + "path => document.body.innerText.includes(path)", + arg=str(page_dir), + ) + result["invocation"]["rendered_input"] = str(page_dir) + elif stable_panels: + input_panel = ( + page.locator('[title="Click to expand"]') + .filter(has_text="Tool Input") + .last + ) + expect(input_panel).to_be_visible() + input_panel.click() + rendered_input = input_panel.locator("pre") + else: + input_panel = page.get_by_text("Tool Input", exact=True).last + expect(input_panel).to_be_visible() + input_panel.click() + rendered_input = input_panel.locator("../..").locator("pre") + if not body_call_record: + expect(rendered_input).to_contain_text(str(page_dir)) + result["invocation"]["rendered_input"] = rendered_input.inner_text() + + outer, app, leaf = base.app_frame(page) + expect( + leaf.get_by_role("heading", name="Where sessions live") + ).to_be_visible() + + inline = base.measure(leaf) + result["inline"] = inline + inline_shot = HERE / "results/full-page-inline.png" + outer.screenshot(path=inline_shot) + + app.get_by_role("button", name="Full screen").click() + if tolerate_mode_control: + page.wait_for_timeout(500) + return_control = app.get_by_role("button", name="Return inline") + mode_control = {"return_visible": return_control.is_visible()} + else: + expect(app.get_by_role("button", name="Return inline")).to_be_visible() + mode_control = {"hidden": False, "text": "Return inline"} + fullscreen = base.measure(leaf) + result["fullscreen"] = fullscreen + result["mode_control"] = mode_control + axe = Axe().run(page.frames[-1]) + fullscreen_shot = HERE / "results/full-page-fullscreen.png" + page.screenshot(path=fullscreen_shot, full_page=True) + + option = leaf.locator("#opt-redis").get_by_role("checkbox") + option.focus() + option.press("Space") + expect(leaf.locator("#opt-redis")).to_have_attribute("chosen", "") + event = read_events(page_dir)[-1] + + complete_interface = None + if exercise_comments_versions: + lede = leaf.locator("#decision-lede") + lede.evaluate( + """node => { + const phrase = 'session state homeless: today it rides the app'; + const text = node.firstChild; + const start = text.data.indexOf(phrase); + if (start < 0) throw new Error('comment phrase not found'); + const range = document.createRange(); + range.setStart(text, start); + range.setEnd(text, start + phrase.length); + const selection = getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); + }""" + ) + comment_button = leaf.locator(".lf-fab") + expect(comment_button).to_be_visible() + comment_button.click() + composer = leaf.locator(".lf-composer") + expect(composer).to_be_visible() + composer.locator("textarea").fill( + "Keep this rationale across versions." + ) + composer.get_by_role("button", name="Comment", exact=True).click() + leaf.locator("body").wait_for(state="visible") + expect(leaf.locator(".lf-thread .lf-quote").first).to_contain_text( + "session state homeless: today it rides the app" + ) + comment = next( + item + for item in reversed(read_events(page_dir)) + if item["kind"] == "comment" + ) + + version = leaf.locator(".lf-version") + expect(version).to_contain_text("v2") + version.click() + leaf.locator('.lf-version-row[data-lf-version="1"]').click() + expect(lede).to_contain_text( + "The monolith split leaves session state homeless" + ) + expect(leaf.locator(".lf-thread .lf-quote").first).to_contain_text( + "session state homeless: today it rides the app" + ) + marked_v1 = leaf.locator("body").evaluate( + "() => CSS.highlights.get('lf-mark')?.size ?? 0" + ) + v1_url = page.frames[-1].url + + leaf.locator(".lf-version").click() + leaf.locator('.lf-version-row[data-lf-version="2"]').click() + expect(lede).to_contain_text( + "The monolith extraction leaves session state homeless" + ) + expect(leaf.locator(".lf-thread .lf-quote").first).to_contain_text( + "session state homeless: today it rides the app" + ) + marked_v2 = leaf.locator("body").evaluate( + "() => CSS.highlights.get('lf-mark')?.size ?? 0" + ) + v2_url = page.frames[-1].url + complete_interface = { + "comment": { + "kind": comment["kind"], + "text": comment["text"], + "revision": comment["revision"], + "anchor": comment["anchor"], + "seq": comment["seq"], + }, + "version_travel": { + "v1_url": v1_url, + "v2_url": v2_url, + "marked_v1": marked_v1, + "marked_v2": marked_v2, + }, + } + + result.update( + { + "axe": { + "violations": axe.violations_count, + "snapshot": axe.generate_snapshot(), + "report": axe.generate_report(), + }, + "keyboard_choice": { + "widget": event["widget"], + "action": event["action"], + "detail": event["detail"], + "seq": event["seq"], + }, + "complete_interface": complete_interface, + "screenshots": { + "inline": str(inline_shot), + "fullscreen": str(fullscreen_shot), + }, + } + ) + except Exception as error: # noqa: BLE001 — preserve evidence before failure + failed = True + failure_shot = HERE / "results/failure.png" + page.screenshot(path=failure_shot, full_page=True) + result["failure"] = { + "error": repr(error), + "url": page.url, + "body": page.locator("body").inner_text(), + "screenshot": str(failure_shot), + } + finally: + result["console"] = console + print(json.dumps(result, indent=2, ensure_ascii=False)) + browser.close() + + if failed: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/16/results/bundle.sha256 b/notes/mcp-apps/experiments/16/results/bundle.sha256 new file mode 100644 index 000000000..a740c9bf3 --- /dev/null +++ b/notes/mcp-apps/experiments/16/results/bundle.sha256 @@ -0,0 +1 @@ +b222706fb57665b1bb7018bdda49afa3f480aacc805819c0c3bad623372aec76 skills/leaf/assets/vendor/mcp-page-app.html diff --git a/notes/mcp-apps/experiments/16/results/mcp-server.log b/notes/mcp-apps/experiments/16/results/mcp-server.log new file mode 100644 index 000000000..01490cfa4 --- /dev/null +++ b/notes/mcp-apps/experiments/16/results/mcp-server.log @@ -0,0 +1,3 @@ +StreamableHTTP session manager started +Created new transport with session ID: 26d64df11ef4431d9114842667e0ec97 +StreamableHTTP session manager shutting down diff --git a/notes/mcp-apps/experiments/16/results/reference-host.json b/notes/mcp-apps/experiments/16/results/reference-host.json new file mode 100644 index 000000000..a7dbb4d94 --- /dev/null +++ b/notes/mcp-apps/experiments/16/results/reference-host.json @@ -0,0 +1,25 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "failure": { + "error": "AssertionError('Locator expected to have count \\'2\\'\\nActual value: 0 \\nCall log:\\n - Expect \"to_have_count\" with timeout 20000ms\\n - waiting for get_by_label(\"Tool\", exact=True).locator(\"option\")\\n 43 × locator resolved to 0 elements\\n - unexpected value \"0\"\\n')", + "url": "http://localhost:8080/?tool=leaf_open_page", + "body": "🌙\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool", + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/16/results/failure.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/16/results/reference-host.log b/notes/mcp-apps/experiments/16/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/16/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/17/README.md b/notes/mcp-apps/experiments/17/README.md new file mode 100644 index 000000000..f1f64ee68 --- /dev/null +++ b/notes/mcp-apps/experiments/17/README.md @@ -0,0 +1,29 @@ +# Experiment 17: Complete-page probe after slow discovery + +## Purpose + +Repeat experiment 16 with enough time for the reference host's MCP discovery to +populate its tool controls. + +**Changes from experiment 16:** + +- Increase the tool-option hydration barrier from 20 to 60 seconds. +- Leave the resource, frame CSP, server lifetime, fixture, payload assertions, + and page measurements unchanged. + +**Expected outcomes:** + +- The host submits the exact page path and the app receives `leaf.page/v1`. +- The loopback frame either renders the normal Leaf runtime or yields the first + direct evidence that a nested origin is blocked. +- A successful run measures both display modes and authors a real keyboard event + through Leaf's ordinary HTTP endpoint. + +## Findings + +The 60-second barrier disproved a slow-discovery explanation. The failure +screenshot shows the fully populated tool select with `leaf_open_page` active, +while Playwright's descendant `option` locator continued to report zero. This is +a faulty harness assertion against the React-controlled select, not a host or +MCP delay. The tool was never called. Experiment 18 waits directly on the +select's value, which is the state the submission handler actually reads. diff --git a/notes/mcp-apps/experiments/17/commands.sh b/notes/mcp-apps/experiments/17/commands.sh new file mode 100644 index 000000000..16214e4ea --- /dev/null +++ b/notes/mcp-apps/experiments/17/commands.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/17/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-17.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" +shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/17/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/17/observe.py b/notes/mcp-apps/experiments/17/observe.py new file mode 100755 index 000000000..cdad5e4f8 --- /dev/null +++ b/notes/mcp-apps/experiments/17/observe.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Repeat the complete-page probe with a measured discovery timeout.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "16/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_16_observe", source) +experiment = module_from_spec(spec) +spec.loader.exec_module(experiment) +experiment.HERE = HERE + + +if __name__ == "__main__": + experiment.main(tool_timeout=60_000) diff --git a/notes/mcp-apps/experiments/17/results/bundle.sha256 b/notes/mcp-apps/experiments/17/results/bundle.sha256 new file mode 100644 index 000000000..a740c9bf3 --- /dev/null +++ b/notes/mcp-apps/experiments/17/results/bundle.sha256 @@ -0,0 +1 @@ +b222706fb57665b1bb7018bdda49afa3f480aacc805819c0c3bad623372aec76 skills/leaf/assets/vendor/mcp-page-app.html diff --git a/notes/mcp-apps/experiments/17/results/mcp-server.log b/notes/mcp-apps/experiments/17/results/mcp-server.log new file mode 100644 index 000000000..265bd5ebd --- /dev/null +++ b/notes/mcp-apps/experiments/17/results/mcp-server.log @@ -0,0 +1,3 @@ +StreamableHTTP session manager started +Created new transport with session ID: cc18c30363054e76b23cea669c99bc1a +StreamableHTTP session manager shutting down diff --git a/notes/mcp-apps/experiments/17/results/reference-host.json b/notes/mcp-apps/experiments/17/results/reference-host.json new file mode 100644 index 000000000..bc6b3c69c --- /dev/null +++ b/notes/mcp-apps/experiments/17/results/reference-host.json @@ -0,0 +1,25 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "failure": { + "error": "AssertionError('Locator expected to have count \\'2\\'\\nActual value: 0 \\nCall log:\\n - Expect \"to_have_count\" with timeout 60000ms\\n - waiting for get_by_label(\"Tool\", exact=True).locator(\"option\")\\n 123 × locator resolved to 0 elements\\n - unexpected value \"0\"\\n')", + "url": "http://localhost:8080/?tool=leaf_open_page", + "body": "🌙\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool", + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/17/results/failure.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/17/results/reference-host.log b/notes/mcp-apps/experiments/17/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/17/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/18/README.md b/notes/mcp-apps/experiments/18/README.md new file mode 100644 index 000000000..bcbf6702b --- /dev/null +++ b/notes/mcp-apps/experiments/18/README.md @@ -0,0 +1,28 @@ +# Experiment 18: Submit from the host's selected value + +## Purpose + +Remove the invalid descendant-option assertion and gate the call on the actual +selected value consumed by the reference host's submission handler. + +**Changes from experiment 17:** + +- Wait directly for the tool select's value to equal `leaf_open_page`. +- Do not query its child options. +- Leave every product, transport, CSP, fixture, and page measurement unchanged. + +**Expected outcomes:** + +- The rendered Tool Input panel proves that the exact page argument was passed. +- The full Leaf iframe then provides direct evidence about loopback framing. +- If framing succeeds, the same run measures layout, accessibility, console + output, fullscreen negotiation, and a real keyboard event. + +## Findings + +The direct value assertion still resolved the host's earlier empty `\\n - unexpected value \"\"\\n\\nAria snapshot:\\n- button \"🌙\"\\n- text: Server\\n- combobox \"Server\":\\n - option \"Leaf\" [selected]\\n- text: Tool\\n- combobox \"Tool\":\\n - option \"leaf_open_compact_ask\"\\n - option \"leaf_open_page\" [selected]\\n- text: Input\\n- textbox \"Input\": \"{}\"\\n- button \"Call Tool\"')", + "url": "http://localhost:8080/?tool=leaf_open_page", + "body": "🌙\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool", + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/18/results/failure.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/18/results/reference-host.log b/notes/mcp-apps/experiments/18/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/18/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/19/README.md b/notes/mcp-apps/experiments/19/README.md new file mode 100644 index 000000000..303c6df6b --- /dev/null +++ b/notes/mcp-apps/experiments/19/README.md @@ -0,0 +1,29 @@ +# Experiment 19: Concrete reference-host form probe + +## Purpose + +Avoid Playwright's stale label resolution during React hydration by observing +the exact DOM value read by the host form. + +**Changes from experiment 18:** + +- Wait in the browser for `document.querySelectorAll('select')[1].value` to be + `leaf_open_page`. +- Address that concrete select for the recorded invocation. +- Keep the Leaf app, framing, fixture, payload, and all page checks unchanged. + +**Expected outcomes:** + +- The tool call finally crosses the reference-host form with its asserted path. +- Nested frame readiness provides direct loopback `frameDomains` evidence. +- On success, inline/fullscreen layout, accessibility, console output, and a + keyboard event characterize how much of Leaf survives unchanged. + +## Findings + +The browser-side select predicate crossed immediately and found +`leaf_open_page`. The next label-based lookup failed on the textarea in the same +way: the timeout accessibility snapshot showed `Input: {}`, but the label +locator saw no element. This confirms the host replaces both labeled controls +during hydration. Experiment 20 addresses the sole textarea directly and still +uses the host's rendered Tool Input panel as independent submission evidence. diff --git a/notes/mcp-apps/experiments/19/commands.sh b/notes/mcp-apps/experiments/19/commands.sh new file mode 100644 index 000000000..5d14503e0 --- /dev/null +++ b/notes/mcp-apps/experiments/19/commands.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/19/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-19.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" +shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/19/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/19/observe.py b/notes/mcp-apps/experiments/19/observe.py new file mode 100755 index 000000000..f09ed66f2 --- /dev/null +++ b/notes/mcp-apps/experiments/19/observe.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Use the reference host's concrete form DOM, then probe the full page.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "16/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_16_observe", source) +experiment = module_from_spec(spec) +spec.loader.exec_module(experiment) +experiment.HERE = HERE + + +if __name__ == "__main__": + experiment.main( + tool_timeout=60_000, + wait_for_options=False, + use_dom_gate=True, + ) diff --git a/notes/mcp-apps/experiments/19/results/bundle.sha256 b/notes/mcp-apps/experiments/19/results/bundle.sha256 new file mode 100644 index 000000000..a740c9bf3 --- /dev/null +++ b/notes/mcp-apps/experiments/19/results/bundle.sha256 @@ -0,0 +1 @@ +b222706fb57665b1bb7018bdda49afa3f480aacc805819c0c3bad623372aec76 skills/leaf/assets/vendor/mcp-page-app.html diff --git a/notes/mcp-apps/experiments/19/results/mcp-server.log b/notes/mcp-apps/experiments/19/results/mcp-server.log new file mode 100644 index 000000000..b4cd6b996 --- /dev/null +++ b/notes/mcp-apps/experiments/19/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: 76a606937d0e4e718e2417234a1d8697 diff --git a/notes/mcp-apps/experiments/19/results/reference-host.json b/notes/mcp-apps/experiments/19/results/reference-host.json new file mode 100644 index 000000000..b55e1cd1e --- /dev/null +++ b/notes/mcp-apps/experiments/19/results/reference-host.json @@ -0,0 +1,25 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "failure": { + "error": "AssertionError('Locator expected to have Value \\'{}\\'\\nActual value: None\\nError: element(s) not found \\nCall log:\\n - Expect \"to_have_value\" with timeout 5000ms\\n - waiting for get_by_label(\"Input\", exact=True)\\n\\nAria snapshot:\\n- button \"🌙\"\\n- text: Server\\n- combobox \"Server\":\\n - option \"Leaf\" [selected]\\n- text: Tool\\n- combobox \"Tool\":\\n - option \"leaf_open_compact_ask\"\\n - option \"leaf_open_page\" [selected]\\n- text: Input\\n- textbox \"Input\": \"{}\"\\n- button \"Call Tool\"')", + "url": "http://localhost:8080/?tool=leaf_open_page", + "body": "🌙\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool", + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/19/results/failure.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/19/results/reference-host.log b/notes/mcp-apps/experiments/19/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/19/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/2/README.md b/notes/mcp-apps/experiments/2/README.md new file mode 100644 index 000000000..780246449 --- /dev/null +++ b/notes/mcp-apps/experiments/2/README.md @@ -0,0 +1,30 @@ +# Experiment 2: Create the result door before launch + +## Purpose + +Run the repository-owned compact ask through the official reference host, preserving experiment 1's code, fixture, host commit, dimensions, and browser actions. + +**Changes from experiment 1:** + +- Create `experiments/2/results/` before redirecting service logs into it. + +**Configuration:** + +- Transport: streamable HTTP adaptation of the shipped stdio MCP server +- UI: disposable `ui://` HTML resource +- Return path: `callServerTool` → Leaf `EventEndpoint` +- Supported ask: one current page-scoped, single-choice `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 reference host exposes only `leaf_open_page` to its model-visible picker, app-only visibility is wired correctly. +- If a keyboard choice appends the declared `choose` event and teardown/reopen returns an empty ask, the app has no second state authority. +- If the app's 420×360 viewport has no horizontal overflow and all three choices are reachable, the compact layout is viable for a substantial single ask. +- If the iframe makes no network request to Leaf's page server, loopback CSP is no longer a prerequisite. + +## Findings + +The focused tests passed and both the Leaf server and reference host launched, proving the missing result directory was the first attempt's blocker. The browser driver then filled the tool input before the host's asynchronous server/tool selection completed; that selection reset the input to `{}`, so Leaf correctly rejected the missing required `page` argument. No event appended and the app did not render. Experiment 3 changes only that host-readiness wait. diff --git a/notes/mcp-apps/experiments/2/commands.sh b/notes/mcp-apps/experiments/2/commands.sh new file mode 100755 index 000000000..de2795d14 --- /dev/null +++ b/notes/mcp-apps/experiments/2/commands.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/2/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-2.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 + +mkdir -p "$results" +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/2/observe.py "$run_dir/page" \ + >"$results/reference-host.json" + +# Open the generated compact-app screenshot: +# open notes/mcp-apps/experiments/2/results/compact-ask-420x360.png diff --git a/notes/mcp-apps/experiments/2/observe.py b/notes/mcp-apps/experiments/2/observe.py new file mode 100755 index 000000000..02bb3fd31 --- /dev/null +++ b/notes/mcp-apps/experiments/2/observe.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Run experiment 1's unchanged browser driver, writing into experiment 2.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "1/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_1_observe", source) +driver = module_from_spec(spec) +spec.loader.exec_module(driver) +driver.HERE = HERE +driver.main() diff --git a/notes/mcp-apps/experiments/2/output.txt b/notes/mcp-apps/experiments/2/output.txt new file mode 100644 index 000000000..5db96a54e --- /dev/null +++ b/notes/mcp-apps/experiments/2/output.txt @@ -0,0 +1,59 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 7 items + +tests/test_mcp_app.py ....... [100%] + +============================== 7 passed in 2.23s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-2.uvT7Vx/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column +Traceback (most recent call last): + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/2/observe.py", line 13, in + driver.main() + ~~~~~~~~~~~^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/1/observe.py", line 79, in main + outer, frame = app_frame(page) + ~~~~~~~~~^^^^^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/1/observe.py", line 27, in app_frame + expect(sandbox.locator("iframe")).to_have_count(1) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/sync_api/_generated.py", line 22365, in to_have_count + self._sync( + ~~~~~~~~~~^ + self._impl_obj.to_have_count( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + count=count, timeout=to_milliseconds(timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + ) + ^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_sync_base.py", line 115, in _sync + return task.result() + ~~~~~~~~~~~^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 465, in to_have_count + await self._expect_impl( + ...<5 lines>... + ) + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 128, in _expect_impl + _record_soft_or_raise( + ~~~~~~~~~~~~~~~~~~~~~^ + AssertionError( + ^^^^^^^^^^^^^^^ + ...<2 lines>... + self._is_soft, + ^^^^^^^^^^^^^^ + ) + ^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 60, in _record_soft_or_raise + raise error +AssertionError: Locator expected to have count '1' +Actual value: 0 +Call log: + - Expect "to_have_count" with timeout 5000ms + - waiting for locator("iframe").content_frame.locator("iframe") + 14 × locator resolved to 0 elements + - unexpected value "0" diff --git a/notes/mcp-apps/experiments/2/results/mcp-server.log b/notes/mcp-apps/experiments/2/results/mcp-server.log new file mode 100644 index 000000000..c1e292fea --- /dev/null +++ b/notes/mcp-apps/experiments/2/results/mcp-server.log @@ -0,0 +1,3 @@ +StreamableHTTP session manager started +Created new transport with session ID: 5523a87fc9354dbfb5ad65e3b13c049f +Tool 'leaf_open_page' rejected arguments: ['page'] diff --git a/notes/mcp-apps/experiments/2/results/reference-host.json b/notes/mcp-apps/experiments/2/results/reference-host.json new file mode 100644 index 000000000..19765bd50 --- /dev/null +++ b/notes/mcp-apps/experiments/2/results/reference-host.json @@ -0,0 +1 @@ +null diff --git a/notes/mcp-apps/experiments/2/results/reference-host.log b/notes/mcp-apps/experiments/2/results/reference-host.log new file mode 100644 index 000000000..54bd5d0c5 --- /dev/null +++ b/notes/mcp-apps/experiments/2/results/reference-host.log @@ -0,0 +1,18 @@ +(node:71241) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead. +(Use `node --trace-deprecation ...` to show where the warning was created) +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop + +NotFoundError: Not Found + at createHttpError (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/send/index.js:861:12) + at SendStream.error (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/send/index.js:168:31) + at SendStream.pipe (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/send/index.js:468:14) + at sendfile (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/express/lib/response.js:1014:8) + at ServerResponse.sendFile (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/express/lib/response.js:411:3) + at (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/examples/basic-host/serve.ts:127:7) + at Layer.handleRequest (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/layer.js:152:17) + at next (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/route.js:157:13) + at Route.dispatch (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/route.js:117:3) + at handle (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/index.js:435:11) diff --git a/notes/mcp-apps/experiments/20/README.md b/notes/mcp-apps/experiments/20/README.md new file mode 100644 index 000000000..9625f2f49 --- /dev/null +++ b/notes/mcp-apps/experiments/20/README.md @@ -0,0 +1,33 @@ +# Experiment 20: Hydration-stable host submission + +## Purpose + +Use concrete DOM controls for the reference host's small form, then rely on its +rendered call record to verify the exact tool payload before probing Leaf. + +**Changes from experiment 19:** + +- Address the sole `textarea` directly instead of through its stale label + locator. +- Retain the concrete selected-tool predicate introduced in experiment 19. +- Leave the Leaf app, frame policy, fixture, and measurements unchanged. + +**Expected outcomes:** + +- The Tool Input record names the submitted page directory exactly. +- The tool result initializes the full-page app and exercises loopback framing. +- A successful run measures both display modes and records a standard Leaf + keyboard event through the existing HTTP/log path. + +## Findings + +The host submitted the exact page path, returned `leaf.page/v1`, loaded the MCP +App, and allowed its nested loopback document. That directly answers the +`frameDomains` question for the official reference host: loopback framing is +allowed. + +The document's `/theme.css` and `/leaf.js` then returned 403. Leaf's handover +request sets a `SameSite=Strict` token cookie, which a browser withholds from +subresources when Leaf is embedded under a different site. Experiment 21 keeps +the ordinary server strict but gives the process-scoped MCP server a secure, +partitioned third-party cookie and presents it on `localhost`. diff --git a/notes/mcp-apps/experiments/20/commands.sh b/notes/mcp-apps/experiments/20/commands.sh new file mode 100644 index 000000000..afc9ce4bf --- /dev/null +++ b/notes/mcp-apps/experiments/20/commands.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/20/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-20.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" +shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/20/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/20/observe.py b/notes/mcp-apps/experiments/20/observe.py new file mode 100755 index 000000000..3cd96b044 --- /dev/null +++ b/notes/mcp-apps/experiments/20/observe.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Submit through hydration-stable controls, then probe the full page.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "16/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_16_observe", source) +experiment = module_from_spec(spec) +spec.loader.exec_module(experiment) +experiment.HERE = HERE + + +if __name__ == "__main__": + experiment.main( + tool_timeout=60_000, + wait_for_options=False, + use_dom_gate=True, + ) diff --git a/notes/mcp-apps/experiments/20/results/bundle.sha256 b/notes/mcp-apps/experiments/20/results/bundle.sha256 new file mode 100644 index 000000000..a740c9bf3 --- /dev/null +++ b/notes/mcp-apps/experiments/20/results/bundle.sha256 @@ -0,0 +1 @@ +b222706fb57665b1bb7018bdda49afa3f480aacc805819c0c3bad623372aec76 skills/leaf/assets/vendor/mcp-page-app.html diff --git a/notes/mcp-apps/experiments/20/results/mcp-server.log b/notes/mcp-apps/experiments/20/results/mcp-server.log new file mode 100644 index 000000000..1dffa657a --- /dev/null +++ b/notes/mcp-apps/experiments/20/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: 68ec798eb93b46f5b0baf70ac030f043 diff --git a/notes/mcp-apps/experiments/20/results/reference-host.json b/notes/mcp-apps/experiments/20/results/reference-host.json new file mode 100644 index 000000000..eb58860a6 --- /dev/null +++ b/notes/mcp-apps/experiments/20/results/reference-host.json @@ -0,0 +1,52 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "invocation": { + "selected_tool": "leaf_open_page", + "initial_input": "{}", + "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-20.wS9C17/page\"}" + }, + "failure": { + "error": "AssertionError('Locator expected to be visible\\nActual value: None\\nError: element(s) not found \\nCall log:\\n - Expect \"to_be_visible\" with timeout 5000ms\\n - waiting for get_by_text(\"Tool Input\", exact=True).last\\n\\nAria snapshot:\\n- button \"🌙\"\\n- text: Leaf:leaf_open_page\\n- button \"×\"\\n- text: \"📥 Tool Input 99 chars ▶ { \\\\\"page\\\\\": \\\\\"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-20.wS9C17/page\\\\\" }\"\\n- iframe\\n- text: \"📤 Tool Result 859 chars ▶ { \\\\\"content\\\\\": [ { \\\\\"type\\\\\": \\\\\"text\\\\\", \\\\\"text\\\\\": \\\\\"Decision: where sessions live: Opening t… Server\"\\n- combobox \"Server\":\\n - option \"Leaf\" [selected]\\n- text: Tool\\n- combobox \"Tool\":\\n - option \"leaf_open_compact_ask\"\\n - option \"leaf_open_page\" [selected]\\n- text: Input\\n- textbox \"Input\": \"{\\\\\"page\\\\\": \\\\\"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-20.wS9C17/page\\\\\"}\"\\n- button \"Call Tool\"')", + "url": "http://localhost:8080/?tool=leaf_open_page&server=Leaf&call=true", + "body": "🌙\nLeaf:leaf_open_page\n×\n📥 Tool Input\n99 chars\n▶\n{ \"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-20.wS9C17/page\" }\n📤 Tool Result\n859 chars\n▶\n{ \"content\": [ { \"type\": \"text\", \"text\": \"Decision: where sessions live: Opening t…\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool", + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/20/results/failure.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + }, + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 403 (Forbidden)", + "location": { + "url": "http://127.0.0.1:54308/theme.css", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + }, + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 403 (Forbidden)", + "location": { + "url": "http://127.0.0.1:54308/leaf.js", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/20/results/reference-host.log b/notes/mcp-apps/experiments/20/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/20/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/21/README.md b/notes/mcp-apps/experiments/21/README.md new file mode 100644 index 000000000..ac71c6855 --- /dev/null +++ b/notes/mcp-apps/experiments/21/README.md @@ -0,0 +1,31 @@ +# Experiment 21: Partitioned Leaf session inside MCP Apps + +## Purpose + +Keep Leaf's token gate intact while making its per-page browser session valid in +a third-party MCP App frame. + +**Changes from experiment 20:** + +- The process-scoped MCP page server sets its token cookie as + `SameSite=None; Secure; Partitioned`. +- Its URL and declared frame origin use `localhost`, where browsers permit the + secure loopback exception; ordinary Leaf servers remain `SameSite=Strict`. +- The host harness addresses the already-rendered Tool Input disclosure by its + stable title instead of an emoji-split text locator. + +**Expected outcomes:** + +- CSS, runtime, state stream, and event POST all carry the partitioned token. +- The normal Leaf page reaches its three readiness stamps inside the MCP App. +- Inline and fullscreen measurements, accessibility, console output, and a + keyboard choice establish the first end-to-end full-interface result. + +## Findings + +The tool call and app loaded with no 403 responses or other Leaf resource errors, +so the secure partitioned cookie crossed the third-party boundary that stopped +experiment 20. The harness then failed while locating the `
` inside an
+already-expanded Tool Input disclosure, even though the host body and
+accessibility tree contained the exact submitted path. Experiment 22 records
+that body-level call evidence directly and continues to Leaf readiness.
diff --git a/notes/mcp-apps/experiments/21/commands.sh b/notes/mcp-apps/experiments/21/commands.sh
new file mode 100644
index 000000000..9a153d09c
--- /dev/null
+++ b/notes/mcp-apps/experiments/21/commands.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+repo="$(git rev-parse --show-toplevel)"
+cd "$repo"
+
+results="$repo/notes/mcp-apps/experiments/21/results"
+run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-21.XXXXXX")"
+reference="/tmp/leaf-mcp-apps-reference-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
+
+mkdir -p "$results"
+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"
+shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256"
+
+test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720"
+(cd "$reference" && npm 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"]' bun --watch 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/21/observe.py "$run_dir/page" \
+  >"$results/reference-host.json"
diff --git a/notes/mcp-apps/experiments/21/observe.py b/notes/mcp-apps/experiments/21/observe.py
new file mode 100755
index 000000000..d0d659f4b
--- /dev/null
+++ b/notes/mcp-apps/experiments/21/observe.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""Probe the complete page with a partitioned loopback session."""
+
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+HERE = Path(__file__).parent
+source = HERE.parent / "16/observe.py"
+spec = spec_from_file_location("mcp_apps_experiment_16_observe", source)
+experiment = module_from_spec(spec)
+spec.loader.exec_module(experiment)
+experiment.HERE = HERE
+
+
+if __name__ == "__main__":
+    experiment.main(
+        tool_timeout=60_000,
+        wait_for_options=False,
+        use_dom_gate=True,
+        stable_panels=True,
+    )
diff --git a/notes/mcp-apps/experiments/21/results/bundle.sha256 b/notes/mcp-apps/experiments/21/results/bundle.sha256
new file mode 100644
index 000000000..11bd39ffd
--- /dev/null
+++ b/notes/mcp-apps/experiments/21/results/bundle.sha256
@@ -0,0 +1 @@
+4e32e5fc698de054a091836d429c7680a626650ffbb143d1b8ac762d784c470c  skills/leaf/assets/vendor/mcp-page-app.html
diff --git a/notes/mcp-apps/experiments/21/results/mcp-server.log b/notes/mcp-apps/experiments/21/results/mcp-server.log
new file mode 100644
index 000000000..1c986c483
--- /dev/null
+++ b/notes/mcp-apps/experiments/21/results/mcp-server.log
@@ -0,0 +1,2 @@
+StreamableHTTP session manager started
+Created new transport with session ID: 4036532dc0a84da2a868e72b888261ed
diff --git a/notes/mcp-apps/experiments/21/results/reference-host.json b/notes/mcp-apps/experiments/21/results/reference-host.json
new file mode 100644
index 000000000..8ab5d577f
--- /dev/null
+++ b/notes/mcp-apps/experiments/21/results/reference-host.json
@@ -0,0 +1,30 @@
+{
+  "reference_host": {
+    "repo": "https://github.com/modelcontextprotocol/ext-apps",
+    "commit": "10195ad91851502134930e9b80ec2c04e277a720"
+  },
+  "invocation": {
+    "selected_tool": "leaf_open_page",
+    "initial_input": "{}",
+    "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-21.F3OfDt/page\"}"
+  },
+  "failure": {
+    "error": "AssertionError('Locator expected to contain text \\'/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-21.F3OfDt/page\\'\\nActual value: None\\nError: element(s) not found \\nCall log:\\n  - Expect \"to_contain_text\" with timeout 5000ms\\n  - waiting for locator(\"[title=\\\\\"Click to expand\\\\\"]\").filter(has_text=\"Tool Input\").last.locator(\"pre\")\\n\\nAria snapshot:\\n- button \"🌙\"\\n- text: Leaf:leaf_open_page\\n- button \"×\"\\n- text: \"📥 Tool Input 99 chars ▼ { \\\\\"page\\\\\": \\\\\"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-21.F3OfDt/page\\\\\" }\"\\n- iframe\\n- text: \"📤 Tool Result 859 chars ▶ { \\\\\"content\\\\\": [ { \\\\\"type\\\\\": \\\\\"text\\\\\", \\\\\"text\\\\\": \\\\\"Decision: where sessions live: Opening t… Server\"\\n- combobox \"Server\":\\n  - option \"Leaf\" [selected]\\n- text: Tool\\n- combobox \"Tool\":\\n  - option \"leaf_open_compact_ask\"\\n  - option \"leaf_open_page\" [selected]\\n- text: Input\\n- textbox \"Input\": \"{\\\\\"page\\\\\": \\\\\"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-21.F3OfDt/page\\\\\"}\"\\n- button \"Call Tool\"')",
+    "url": "http://localhost:8080/?tool=leaf_open_page&server=Leaf&call=true",
+    "body": "🌙\nLeaf:leaf_open_page\n×\n📥 Tool Input\n99 chars\n▼\n{\n  \"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-21.F3OfDt/page\"\n}\n📤 Tool Result\n859 chars\n▶\n{ \"content\": [ { \"type\": \"text\", \"text\": \"Decision: where sessions live: Opening t…\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool",
+    "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/21/results/failure.png"
+  },
+  "console": [
+    {
+      "type": "error",
+      "text": "Failed to load resource: the server responded with a status of 404 (Not Found)",
+      "location": {
+        "url": "http://localhost:8080/favicon.ico",
+        "line": 0,
+        "column": 0,
+        "lineNumber": 0,
+        "columnNumber": 0
+      }
+    }
+  ]
+}
diff --git a/notes/mcp-apps/experiments/21/results/reference-host.log b/notes/mcp-apps/experiments/21/results/reference-host.log
new file mode 100644
index 000000000..045bfdd35
--- /dev/null
+++ b/notes/mcp-apps/experiments/21/results/reference-host.log
@@ -0,0 +1,4 @@
+Host server:    http://localhost:8080
+Sandbox server: http://localhost:8081
+
+Press Ctrl+C to stop
diff --git a/notes/mcp-apps/experiments/22/README.md b/notes/mcp-apps/experiments/22/README.md
new file mode 100644
index 000000000..707926c27
--- /dev/null
+++ b/notes/mcp-apps/experiments/22/README.md
@@ -0,0 +1,28 @@
+# Experiment 22: End-to-end complete Leaf page
+
+## Purpose
+
+Continue past the reference host's unstable diagnostic subtree and measure the
+now-authorized full Leaf page.
+
+**Changes from experiment 21:**
+
+- Verify the submitted page path in the host's rendered body rather than in a
+  replaced `
` child.
+- Change no Leaf code, cookie policy, frame declaration, fixture, or page check.
+
+**Expected outcomes:**
+
+- The normal Leaf runtime reaches upgraded, applied, and presented readiness.
+- The inline surface remains a constrained preview and fullscreen restores the
+  ordinary page layout.
+- Accessibility and console readings are clean, and a keyboard choice appends
+  the normal action event to the page log.
+
+## Findings
+
+The host body verified the exact page path, but the new bypass then referenced
+the skipped disclosure locator variable and exited with `UnboundLocalError`.
+This is solely experiment control flow; the tool call and app had no console
+errors before the typo. Experiment 23 scopes that locator assertion to the old
+diagnostic branches and changes nothing under test.
diff --git a/notes/mcp-apps/experiments/22/commands.sh b/notes/mcp-apps/experiments/22/commands.sh
new file mode 100644
index 000000000..d199fa725
--- /dev/null
+++ b/notes/mcp-apps/experiments/22/commands.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+repo="$(git rev-parse --show-toplevel)"
+cd "$repo"
+
+results="$repo/notes/mcp-apps/experiments/22/results"
+run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-22.XXXXXX")"
+reference="/tmp/leaf-mcp-apps-reference-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
+
+mkdir -p "$results"
+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"
+shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256"
+
+test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720"
+(cd "$reference" && npm 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"]' bun --watch 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/22/observe.py "$run_dir/page" \
+  >"$results/reference-host.json"
diff --git a/notes/mcp-apps/experiments/22/observe.py b/notes/mcp-apps/experiments/22/observe.py
new file mode 100755
index 000000000..57f141f71
--- /dev/null
+++ b/notes/mcp-apps/experiments/22/observe.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""Measure the complete Leaf page after host call verification."""
+
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+HERE = Path(__file__).parent
+source = HERE.parent / "16/observe.py"
+spec = spec_from_file_location("mcp_apps_experiment_16_observe", source)
+experiment = module_from_spec(spec)
+spec.loader.exec_module(experiment)
+experiment.HERE = HERE
+
+
+if __name__ == "__main__":
+    experiment.main(
+        tool_timeout=60_000,
+        wait_for_options=False,
+        use_dom_gate=True,
+        body_call_record=True,
+    )
diff --git a/notes/mcp-apps/experiments/22/results/bundle.sha256 b/notes/mcp-apps/experiments/22/results/bundle.sha256
new file mode 100644
index 000000000..11bd39ffd
--- /dev/null
+++ b/notes/mcp-apps/experiments/22/results/bundle.sha256
@@ -0,0 +1 @@
+4e32e5fc698de054a091836d429c7680a626650ffbb143d1b8ac762d784c470c  skills/leaf/assets/vendor/mcp-page-app.html
diff --git a/notes/mcp-apps/experiments/22/results/mcp-server.log b/notes/mcp-apps/experiments/22/results/mcp-server.log
new file mode 100644
index 000000000..a5ee77efe
--- /dev/null
+++ b/notes/mcp-apps/experiments/22/results/mcp-server.log
@@ -0,0 +1,2 @@
+StreamableHTTP session manager started
+Created new transport with session ID: 6fd1ac7a105244bbbb47a15ce37bc8d1
diff --git a/notes/mcp-apps/experiments/22/results/reference-host.json b/notes/mcp-apps/experiments/22/results/reference-host.json
new file mode 100644
index 000000000..c58d0b0d2
--- /dev/null
+++ b/notes/mcp-apps/experiments/22/results/reference-host.json
@@ -0,0 +1,31 @@
+{
+  "reference_host": {
+    "repo": "https://github.com/modelcontextprotocol/ext-apps",
+    "commit": "10195ad91851502134930e9b80ec2c04e277a720"
+  },
+  "invocation": {
+    "selected_tool": "leaf_open_page",
+    "initial_input": "{}",
+    "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-22.tkp3OV/page\"}",
+    "rendered_input": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-22.tkp3OV/page"
+  },
+  "failure": {
+    "error": "UnboundLocalError(\"cannot access local variable 'rendered_input' where it is not associated with a value\")",
+    "url": "http://localhost:8080/?tool=leaf_open_page&server=Leaf&call=true",
+    "body": "🌙\nLeaf:leaf_open_page\n×\n📥 Tool Input\n99 chars\n▶\n{ \"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-22.tkp3OV/page\" }\nLoading result...\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool",
+    "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/22/results/failure.png"
+  },
+  "console": [
+    {
+      "type": "error",
+      "text": "Failed to load resource: the server responded with a status of 404 (Not Found)",
+      "location": {
+        "url": "http://localhost:8080/favicon.ico",
+        "line": 0,
+        "column": 0,
+        "lineNumber": 0,
+        "columnNumber": 0
+      }
+    }
+  ]
+}
diff --git a/notes/mcp-apps/experiments/22/results/reference-host.log b/notes/mcp-apps/experiments/22/results/reference-host.log
new file mode 100644
index 000000000..045bfdd35
--- /dev/null
+++ b/notes/mcp-apps/experiments/22/results/reference-host.log
@@ -0,0 +1,4 @@
+Host server:    http://localhost:8080
+Sandbox server: http://localhost:8081
+
+Press Ctrl+C to stop
diff --git a/notes/mcp-apps/experiments/23/README.md b/notes/mcp-apps/experiments/23/README.md
new file mode 100644
index 000000000..5a6778162
--- /dev/null
+++ b/notes/mcp-apps/experiments/23/README.md
@@ -0,0 +1,30 @@
+# Experiment 23: Corrected end-to-end page probe
+
+## Purpose
+
+Repeat the complete-page measurement after correcting the body-evidence branch's
+local-variable scope.
+
+**Changes from experiment 22:**
+
+- Skip the disclosure-locator assertion when body-level call evidence is used.
+- Change no implementation, host, fixture, or page measurement.
+
+**Expected outcomes:**
+
+- Leaf reaches upgraded, applied, and presented readiness in the nested frame.
+- Inline and fullscreen readings characterize the usable surface.
+- Axe, console, and keyboard event evidence cover accessibility and the durable
+  return path.
+
+## Findings
+
+The complete Leaf page reached all readiness checks inside the MCP App. The
+inline screenshot contains the ordinary Leaf status/navigation bar, document,
+widgets, key hints, and live page footer; there were no Leaf console errors.
+Requesting fullscreen also expanded the app to the host viewport.
+
+The reference host then hid the app's display-mode button instead of exposing
+the requested `Return inline` label, so the harness stopped before Axe and the
+keyboard event. Experiment 24 records the post-request dimensions and control
+state without requiring a return control, then completes those remaining checks.
diff --git a/notes/mcp-apps/experiments/23/commands.sh b/notes/mcp-apps/experiments/23/commands.sh
new file mode 100644
index 000000000..c03a642f3
--- /dev/null
+++ b/notes/mcp-apps/experiments/23/commands.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+repo="$(git rev-parse --show-toplevel)"
+cd "$repo"
+
+results="$repo/notes/mcp-apps/experiments/23/results"
+run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-23.XXXXXX")"
+reference="/tmp/leaf-mcp-apps-reference-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
+
+mkdir -p "$results"
+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"
+shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256"
+
+test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720"
+(cd "$reference" && npm 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"]' bun --watch 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/23/observe.py "$run_dir/page" \
+  >"$results/reference-host.json"
diff --git a/notes/mcp-apps/experiments/23/observe.py b/notes/mcp-apps/experiments/23/observe.py
new file mode 100755
index 000000000..c4327186b
--- /dev/null
+++ b/notes/mcp-apps/experiments/23/observe.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""Run the corrected end-to-end complete-page measurement."""
+
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+HERE = Path(__file__).parent
+source = HERE.parent / "16/observe.py"
+spec = spec_from_file_location("mcp_apps_experiment_16_observe", source)
+experiment = module_from_spec(spec)
+spec.loader.exec_module(experiment)
+experiment.HERE = HERE
+
+
+if __name__ == "__main__":
+    experiment.main(
+        tool_timeout=60_000,
+        wait_for_options=False,
+        use_dom_gate=True,
+        body_call_record=True,
+    )
diff --git a/notes/mcp-apps/experiments/23/results/bundle.sha256 b/notes/mcp-apps/experiments/23/results/bundle.sha256
new file mode 100644
index 000000000..11bd39ffd
--- /dev/null
+++ b/notes/mcp-apps/experiments/23/results/bundle.sha256
@@ -0,0 +1 @@
+4e32e5fc698de054a091836d429c7680a626650ffbb143d1b8ac762d784c470c  skills/leaf/assets/vendor/mcp-page-app.html
diff --git a/notes/mcp-apps/experiments/23/results/mcp-server.log b/notes/mcp-apps/experiments/23/results/mcp-server.log
new file mode 100644
index 000000000..c114d9c3b
--- /dev/null
+++ b/notes/mcp-apps/experiments/23/results/mcp-server.log
@@ -0,0 +1,2 @@
+StreamableHTTP session manager started
+Created new transport with session ID: c9405edf66bb4c7b84d99a9a69f2f9b2
diff --git a/notes/mcp-apps/experiments/23/results/reference-host.json b/notes/mcp-apps/experiments/23/results/reference-host.json
new file mode 100644
index 000000000..d50f3b989
--- /dev/null
+++ b/notes/mcp-apps/experiments/23/results/reference-host.json
@@ -0,0 +1,31 @@
+{
+  "reference_host": {
+    "repo": "https://github.com/modelcontextprotocol/ext-apps",
+    "commit": "10195ad91851502134930e9b80ec2c04e277a720"
+  },
+  "invocation": {
+    "selected_tool": "leaf_open_page",
+    "initial_input": "{}",
+    "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-23.tyyIsW/page\"}",
+    "rendered_input": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-23.tyyIsW/page"
+  },
+  "failure": {
+    "error": "AssertionError('Locator expected to be visible\\nActual value: None\\nError: element(s) not found \\nCall log:\\n  - Expect \"to_be_visible\" with timeout 5000ms\\n  - waiting for locator(\"iframe\").content_frame.locator(\"iframe\").content_frame.get_by_role(\"button\", name=\"Return inline\")\\n\\nAria snapshot:\\n- button \"🌙\"\\n- text: Leaf:leaf_open_page\\n- button \"×\"\\n- text: \"📥 Tool Input 99 chars ▶ { \\\\\"page\\\\\": \\\\\"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-23.tyyIsW/page\\\\\" }\"\\n- iframe\\n- text: \"📤 Tool Result 859 chars ▶ { \\\\\"content\\\\\": [ { \\\\\"type\\\\\": \\\\\"text\\\\\", \\\\\"text\\\\\": \\\\\"Decision: where sessions live: Opening t… Server\"\\n- combobox \"Server\":\\n  - option \"Leaf\" [selected]\\n- text: Tool\\n- combobox \"Tool\":\\n  - option \"leaf_open_compact_ask\"\\n  - option \"leaf_open_page\" [selected]\\n- text: Input\\n- textbox \"Input\": \"{\\\\\"page\\\\\": \\\\\"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-23.tyyIsW/page\\\\\"}\"\\n- button \"Call Tool\"')",
+    "url": "http://localhost:8080/?tool=leaf_open_page&server=Leaf&call=true",
+    "body": "🌙\nLeaf:leaf_open_page\n×\n📥 Tool Input\n99 chars\n▶\n{ \"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-23.tyyIsW/page\" }\n📤 Tool Result\n859 chars\n▶\n{ \"content\": [ { \"type\": \"text\", \"text\": \"Decision: where sessions live: Opening t…\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool",
+    "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/23/results/failure.png"
+  },
+  "console": [
+    {
+      "type": "error",
+      "text": "Failed to load resource: the server responded with a status of 404 (Not Found)",
+      "location": {
+        "url": "http://localhost:8080/favicon.ico",
+        "line": 0,
+        "column": 0,
+        "lineNumber": 0,
+        "columnNumber": 0
+      }
+    }
+  ]
+}
diff --git a/notes/mcp-apps/experiments/23/results/reference-host.log b/notes/mcp-apps/experiments/23/results/reference-host.log
new file mode 100644
index 000000000..045bfdd35
--- /dev/null
+++ b/notes/mcp-apps/experiments/23/results/reference-host.log
@@ -0,0 +1,4 @@
+Host server:    http://localhost:8080
+Sandbox server: http://localhost:8081
+
+Press Ctrl+C to stop
diff --git a/notes/mcp-apps/experiments/24/README.md b/notes/mcp-apps/experiments/24/README.md
new file mode 100644
index 000000000..7514de3d1
--- /dev/null
+++ b/notes/mcp-apps/experiments/24/README.md
@@ -0,0 +1,29 @@
+# Experiment 24: Full-page accessibility and return path
+
+## Purpose
+
+Complete the successful full-page run while recording, rather than rejecting,
+the reference host's post-fullscreen display-control state.
+
+**Changes from experiment 23:**
+
+- After requesting fullscreen, record the button's hidden/text state and the
+  resulting page dimensions without requiring `Return inline` to be visible.
+- Continue to Axe and the keyboard choice.
+- Change no Leaf implementation, resource, cookie, fixture, or interaction.
+
+**Expected outcomes:**
+
+- Inline and fullscreen dimensions confirm the layout transition.
+- Axe finds no serious surface regression and the console contains no Leaf
+  failures.
+- Choosing Redis from the nested iframe appends an ordinary `choose` action to
+  the page's durable event log.
+
+## Findings
+
+Inline Leaf measured 1060×332 with a 2,857px document, no horizontal overflow,
+all five representative widgets present, and runtime presentation complete.
+The run then called `evaluate` through the wrong Playwright frame-locator API
+while reading the now-hidden fullscreen button. Experiment 25 uses the same
+role locator that successfully clicked the button, then continues unchanged.
diff --git a/notes/mcp-apps/experiments/24/commands.sh b/notes/mcp-apps/experiments/24/commands.sh
new file mode 100644
index 000000000..278bdd644
--- /dev/null
+++ b/notes/mcp-apps/experiments/24/commands.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+repo="$(git rev-parse --show-toplevel)"
+cd "$repo"
+
+results="$repo/notes/mcp-apps/experiments/24/results"
+run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-24.XXXXXX")"
+reference="/tmp/leaf-mcp-apps-reference-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
+
+mkdir -p "$results"
+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"
+shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256"
+
+test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720"
+(cd "$reference" && npm 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"]' bun --watch 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/24/observe.py "$run_dir/page" \
+  >"$results/reference-host.json"
diff --git a/notes/mcp-apps/experiments/24/observe.py b/notes/mcp-apps/experiments/24/observe.py
new file mode 100755
index 000000000..370927352
--- /dev/null
+++ b/notes/mcp-apps/experiments/24/observe.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+"""Complete accessibility and event checks after fullscreen negotiation."""
+
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+HERE = Path(__file__).parent
+source = HERE.parent / "16/observe.py"
+spec = spec_from_file_location("mcp_apps_experiment_16_observe", source)
+experiment = module_from_spec(spec)
+spec.loader.exec_module(experiment)
+experiment.HERE = HERE
+
+
+if __name__ == "__main__":
+    experiment.main(
+        tool_timeout=60_000,
+        wait_for_options=False,
+        use_dom_gate=True,
+        body_call_record=True,
+        tolerate_mode_control=True,
+    )
diff --git a/notes/mcp-apps/experiments/24/results/bundle.sha256 b/notes/mcp-apps/experiments/24/results/bundle.sha256
new file mode 100644
index 000000000..11bd39ffd
--- /dev/null
+++ b/notes/mcp-apps/experiments/24/results/bundle.sha256
@@ -0,0 +1 @@
+4e32e5fc698de054a091836d429c7680a626650ffbb143d1b8ac762d784c470c  skills/leaf/assets/vendor/mcp-page-app.html
diff --git a/notes/mcp-apps/experiments/24/results/mcp-server.log b/notes/mcp-apps/experiments/24/results/mcp-server.log
new file mode 100644
index 000000000..e02f56a1f
--- /dev/null
+++ b/notes/mcp-apps/experiments/24/results/mcp-server.log
@@ -0,0 +1,2 @@
+StreamableHTTP session manager started
+Created new transport with session ID: 2c083b4b75604613a6feafdc6b2c6ac6
diff --git a/notes/mcp-apps/experiments/24/results/reference-host.json b/notes/mcp-apps/experiments/24/results/reference-host.json
new file mode 100644
index 000000000..2efa7263d
--- /dev/null
+++ b/notes/mcp-apps/experiments/24/results/reference-host.json
@@ -0,0 +1,63 @@
+{
+  "reference_host": {
+    "repo": "https://github.com/modelcontextprotocol/ext-apps",
+    "commit": "10195ad91851502134930e9b80ec2c04e277a720"
+  },
+  "invocation": {
+    "selected_tool": "leaf_open_page",
+    "initial_input": "{}",
+    "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-24.5rf3NX/page\"}",
+    "rendered_input": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-24.5rf3NX/page"
+  },
+  "inline": {
+    "viewport": {
+      "width": 1060,
+      "height": 332
+    },
+    "document": {
+      "client_width": 1060,
+      "client_height": 332,
+      "scroll_width": 1060,
+      "scroll_height": 2857
+    },
+    "stamps": {
+      "upgraded": "1",
+      "applied": "0",
+      "presented": "1"
+    },
+    "headings": [
+      "Where sessions live",
+      "Constraints",
+      "Settled: the session id travels in a host-only Lax cookie",
+      "The payments precedent is useful — and not equivalent",
+      "Where should a session live?",
+      "The two cookies"
+    ],
+    "widgets": [
+      "lf-gloss",
+      "lf-options",
+      "lf-compare",
+      "lf-options",
+      "lf-compare"
+    ]
+  },
+  "failure": {
+    "error": "AttributeError(\"'FrameLocator' object has no attribute 'evaluate'\")",
+    "url": "http://localhost:8080/?tool=leaf_open_page&server=Leaf&call=true",
+    "body": "🌙\nLeaf:leaf_open_page\n×\n📥 Tool Input\n99 chars\n▶\n{ \"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-24.5rf3NX/page\" }\n📤 Tool Result\n859 chars\n▶\n{ \"content\": [ { \"type\": \"text\", \"text\": \"Decision: where sessions live: Opening t…\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool",
+    "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/24/results/failure.png"
+  },
+  "console": [
+    {
+      "type": "error",
+      "text": "Failed to load resource: the server responded with a status of 404 (Not Found)",
+      "location": {
+        "url": "http://localhost:8080/favicon.ico",
+        "line": 0,
+        "column": 0,
+        "lineNumber": 0,
+        "columnNumber": 0
+      }
+    }
+  ]
+}
diff --git a/notes/mcp-apps/experiments/24/results/reference-host.log b/notes/mcp-apps/experiments/24/results/reference-host.log
new file mode 100644
index 000000000..045bfdd35
--- /dev/null
+++ b/notes/mcp-apps/experiments/24/results/reference-host.log
@@ -0,0 +1,4 @@
+Host server:    http://localhost:8080
+Sandbox server: http://localhost:8081
+
+Press Ctrl+C to stop
diff --git a/notes/mcp-apps/experiments/25/README.md b/notes/mcp-apps/experiments/25/README.md
new file mode 100644
index 000000000..4143b045e
--- /dev/null
+++ b/notes/mcp-apps/experiments/25/README.md
@@ -0,0 +1,27 @@
+# Experiment 25: Completed full-page evidence
+
+## Purpose
+
+Finish the full-page measurement with a valid read of the host's post-fullscreen
+return control.
+
+**Changes from experiment 24:**
+
+- Read `Return inline` visibility from a role locator instead of calling a
+  locator method through the frame locator.
+- Change no implementation, host, fixture, or other measurement.
+
+**Expected outcomes:**
+
+- Fullscreen dimensions and the hidden return-control quirk are both recorded.
+- Axe and console output characterize the composed app.
+- A nested keyboard choice appends and replays as an ordinary durable Leaf
+  action.
+
+## Findings
+
+The page again reached fullscreen, then Axe rejected the Playwright
+`FrameLocator`; the wrapper requires a concrete `Frame`. Earlier successful MCP
+App experiments already use `page.frames[-1]` for this exact nested boundary.
+Experiment 26 adopts that proven call and persists fullscreen readings before
+running Axe.
diff --git a/notes/mcp-apps/experiments/25/commands.sh b/notes/mcp-apps/experiments/25/commands.sh
new file mode 100644
index 000000000..988bc746a
--- /dev/null
+++ b/notes/mcp-apps/experiments/25/commands.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+repo="$(git rev-parse --show-toplevel)"
+cd "$repo"
+
+results="$repo/notes/mcp-apps/experiments/25/results"
+run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-25.XXXXXX")"
+reference="/tmp/leaf-mcp-apps-reference-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
+
+mkdir -p "$results"
+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"
+shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256"
+
+test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720"
+(cd "$reference" && npm 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"]' bun --watch 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/25/observe.py "$run_dir/page" \
+  >"$results/reference-host.json"
diff --git a/notes/mcp-apps/experiments/25/observe.py b/notes/mcp-apps/experiments/25/observe.py
new file mode 100755
index 000000000..72fdddafe
--- /dev/null
+++ b/notes/mcp-apps/experiments/25/observe.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+"""Complete the full-page accessibility and durable-event measurement."""
+
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+HERE = Path(__file__).parent
+source = HERE.parent / "16/observe.py"
+spec = spec_from_file_location("mcp_apps_experiment_16_observe", source)
+experiment = module_from_spec(spec)
+spec.loader.exec_module(experiment)
+experiment.HERE = HERE
+
+
+if __name__ == "__main__":
+    experiment.main(
+        tool_timeout=60_000,
+        wait_for_options=False,
+        use_dom_gate=True,
+        body_call_record=True,
+        tolerate_mode_control=True,
+    )
diff --git a/notes/mcp-apps/experiments/25/results/bundle.sha256 b/notes/mcp-apps/experiments/25/results/bundle.sha256
new file mode 100644
index 000000000..11bd39ffd
--- /dev/null
+++ b/notes/mcp-apps/experiments/25/results/bundle.sha256
@@ -0,0 +1 @@
+4e32e5fc698de054a091836d429c7680a626650ffbb143d1b8ac762d784c470c  skills/leaf/assets/vendor/mcp-page-app.html
diff --git a/notes/mcp-apps/experiments/25/results/mcp-server.log b/notes/mcp-apps/experiments/25/results/mcp-server.log
new file mode 100644
index 000000000..d4632187c
--- /dev/null
+++ b/notes/mcp-apps/experiments/25/results/mcp-server.log
@@ -0,0 +1,2 @@
+StreamableHTTP session manager started
+Created new transport with session ID: 30d7c6598f764679a35817c58a33aa37
diff --git a/notes/mcp-apps/experiments/25/results/reference-host.json b/notes/mcp-apps/experiments/25/results/reference-host.json
new file mode 100644
index 000000000..ba4077cfe
--- /dev/null
+++ b/notes/mcp-apps/experiments/25/results/reference-host.json
@@ -0,0 +1,63 @@
+{
+  "reference_host": {
+    "repo": "https://github.com/modelcontextprotocol/ext-apps",
+    "commit": "10195ad91851502134930e9b80ec2c04e277a720"
+  },
+  "invocation": {
+    "selected_tool": "leaf_open_page",
+    "initial_input": "{}",
+    "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-25.BowDLq/page\"}",
+    "rendered_input": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-25.BowDLq/page"
+  },
+  "inline": {
+    "viewport": {
+      "width": 1060,
+      "height": 332
+    },
+    "document": {
+      "client_width": 1060,
+      "client_height": 332,
+      "scroll_width": 1060,
+      "scroll_height": 2857
+    },
+    "stamps": {
+      "upgraded": "1",
+      "applied": "0",
+      "presented": "1"
+    },
+    "headings": [
+      "Where sessions live",
+      "Constraints",
+      "Settled: the session id travels in a host-only Lax cookie",
+      "The payments precedent is useful — and not equivalent",
+      "Where should a session live?",
+      "The two cookies"
+    ],
+    "widgets": [
+      "lf-gloss",
+      "lf-options",
+      "lf-compare",
+      "lf-options",
+      "lf-compare"
+    ]
+  },
+  "failure": {
+    "error": "AttributeError(\"'FrameLocator' object has no attribute 'evaluate'\")",
+    "url": "http://localhost:8080/?tool=leaf_open_page&server=Leaf&call=true",
+    "body": "🌙\nLeaf:leaf_open_page\n×\n📥 Tool Input\n99 chars\n▶\n{ \"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-25.BowDLq/page\" }\n📤 Tool Result\n859 chars\n▶\n{ \"content\": [ { \"type\": \"text\", \"text\": \"Decision: where sessions live: Opening t…\nServer\nLeaf\nTool\nleaf_open_compact_ask\nleaf_open_page\nInput\nCall Tool",
+    "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/25/results/failure.png"
+  },
+  "console": [
+    {
+      "type": "error",
+      "text": "Failed to load resource: the server responded with a status of 404 (Not Found)",
+      "location": {
+        "url": "http://localhost:8080/favicon.ico",
+        "line": 0,
+        "column": 0,
+        "lineNumber": 0,
+        "columnNumber": 0
+      }
+    }
+  ]
+}
diff --git a/notes/mcp-apps/experiments/25/results/reference-host.log b/notes/mcp-apps/experiments/25/results/reference-host.log
new file mode 100644
index 000000000..045bfdd35
--- /dev/null
+++ b/notes/mcp-apps/experiments/25/results/reference-host.log
@@ -0,0 +1,4 @@
+Host server:    http://localhost:8080
+Sandbox server: http://localhost:8081
+
+Press Ctrl+C to stop
diff --git a/notes/mcp-apps/experiments/26/README.md b/notes/mcp-apps/experiments/26/README.md
new file mode 100644
index 000000000..cdc7e1259
--- /dev/null
+++ b/notes/mcp-apps/experiments/26/README.md
@@ -0,0 +1,34 @@
+# Experiment 26: Concrete-frame accessibility probe
+
+## Purpose
+
+Run Axe against the concrete nested Leaf frame, following the proven compact-app
+experiment pattern, and complete the durable event check.
+
+**Changes from experiment 25:**
+
+- Pass `page.frames[-1]` to Axe instead of a `FrameLocator`.
+- Persist fullscreen dimensions before Axe runs.
+- Change no Leaf implementation or browser interaction.
+
+**Expected outcomes:**
+
+- Inline/fullscreen measurements and the host control state survive separately.
+- Axe completes against the Leaf document.
+- A keyboard choice posts through Leaf's existing server and appears in the
+  append-only page log.
+
+## Findings
+
+The full experiment completed. Inline Leaf was 1060×332 and fullscreen was
+1068×806; neither had horizontal overflow, and both retained the complete
+2,857px document, six headings, five representative widgets, and runtime
+readiness. A Space press on Redis appended the ordinary
+`session-options / choose / opt-redis` event at sequence 1.
+
+Axe reported one moderate `region` violation, still needing its node detail.
+The only console error was the reference host's missing `/favicon.ico`. The
+fullscreen return control was hidden because Leaf treated a partial host-context
+notification (container dimensions only) as an empty capabilities list.
+Experiment 27 preserves capabilities when that field is absent and records the
+full Axe report.
diff --git a/notes/mcp-apps/experiments/26/commands.sh b/notes/mcp-apps/experiments/26/commands.sh
new file mode 100644
index 000000000..63ea9e49e
--- /dev/null
+++ b/notes/mcp-apps/experiments/26/commands.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+repo="$(git rev-parse --show-toplevel)"
+cd "$repo"
+
+results="$repo/notes/mcp-apps/experiments/26/results"
+run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-26.XXXXXX")"
+reference="/tmp/leaf-mcp-apps-reference-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
+
+mkdir -p "$results"
+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"
+shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256"
+
+test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720"
+(cd "$reference" && npm 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"]' bun --watch 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/26/observe.py "$run_dir/page" \
+  >"$results/reference-host.json"
diff --git a/notes/mcp-apps/experiments/26/observe.py b/notes/mcp-apps/experiments/26/observe.py
new file mode 100755
index 000000000..e7373e51a
--- /dev/null
+++ b/notes/mcp-apps/experiments/26/observe.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+"""Run Axe in the concrete Leaf frame and verify its durable event."""
+
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+HERE = Path(__file__).parent
+source = HERE.parent / "16/observe.py"
+spec = spec_from_file_location("mcp_apps_experiment_16_observe", source)
+experiment = module_from_spec(spec)
+spec.loader.exec_module(experiment)
+experiment.HERE = HERE
+
+
+if __name__ == "__main__":
+    experiment.main(
+        tool_timeout=60_000,
+        wait_for_options=False,
+        use_dom_gate=True,
+        body_call_record=True,
+        tolerate_mode_control=True,
+    )
diff --git a/notes/mcp-apps/experiments/26/results/bundle.sha256 b/notes/mcp-apps/experiments/26/results/bundle.sha256
new file mode 100644
index 000000000..11bd39ffd
--- /dev/null
+++ b/notes/mcp-apps/experiments/26/results/bundle.sha256
@@ -0,0 +1 @@
+4e32e5fc698de054a091836d429c7680a626650ffbb143d1b8ac762d784c470c  skills/leaf/assets/vendor/mcp-page-app.html
diff --git a/notes/mcp-apps/experiments/26/results/mcp-server.log b/notes/mcp-apps/experiments/26/results/mcp-server.log
new file mode 100644
index 000000000..409d67ffd
--- /dev/null
+++ b/notes/mcp-apps/experiments/26/results/mcp-server.log
@@ -0,0 +1,2 @@
+StreamableHTTP session manager started
+Created new transport with session ID: 34607a5ed8444fa985a09fd828496dcd
diff --git a/notes/mcp-apps/experiments/26/results/reference-host.json b/notes/mcp-apps/experiments/26/results/reference-host.json
new file mode 100644
index 000000000..561b22e3d
--- /dev/null
+++ b/notes/mcp-apps/experiments/26/results/reference-host.json
@@ -0,0 +1,110 @@
+{
+  "reference_host": {
+    "repo": "https://github.com/modelcontextprotocol/ext-apps",
+    "commit": "10195ad91851502134930e9b80ec2c04e277a720"
+  },
+  "invocation": {
+    "selected_tool": "leaf_open_page",
+    "initial_input": "{}",
+    "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-26.YMIBek/page\"}",
+    "rendered_input": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-26.YMIBek/page"
+  },
+  "inline": {
+    "viewport": {
+      "width": 1060,
+      "height": 332
+    },
+    "document": {
+      "client_width": 1060,
+      "client_height": 332,
+      "scroll_width": 1060,
+      "scroll_height": 2857
+    },
+    "stamps": {
+      "upgraded": "1",
+      "applied": "0",
+      "presented": "1"
+    },
+    "headings": [
+      "Where sessions live",
+      "Constraints",
+      "Settled: the session id travels in a host-only Lax cookie",
+      "The payments precedent is useful — and not equivalent",
+      "Where should a session live?",
+      "The two cookies"
+    ],
+    "widgets": [
+      "lf-gloss",
+      "lf-options",
+      "lf-compare",
+      "lf-options",
+      "lf-compare"
+    ]
+  },
+  "fullscreen": {
+    "viewport": {
+      "width": 1068,
+      "height": 806
+    },
+    "document": {
+      "client_width": 1068,
+      "client_height": 806,
+      "scroll_width": 1068,
+      "scroll_height": 2857
+    },
+    "stamps": {
+      "upgraded": "1",
+      "applied": "0",
+      "presented": "1"
+    },
+    "headings": [
+      "Where sessions live",
+      "Constraints",
+      "Settled: the session id travels in a host-only Lax cookie",
+      "The payments precedent is useful — and not equivalent",
+      "Where should a session live?",
+      "The two cookies"
+    ],
+    "widgets": [
+      "lf-gloss",
+      "lf-options",
+      "lf-compare",
+      "lf-options",
+      "lf-compare"
+    ]
+  },
+  "mode_control": {
+    "return_visible": false
+  },
+  "axe": {
+    "violations": 1,
+    "snapshot": "region (moderate) : 1"
+  },
+  "keyboard_choice": {
+    "widget": "session-options",
+    "action": "choose",
+    "detail": {
+      "options": [
+        "opt-redis"
+      ]
+    },
+    "seq": 1
+  },
+  "screenshots": {
+    "inline": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/26/results/full-page-inline.png",
+    "fullscreen": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/26/results/full-page-fullscreen.png"
+  },
+  "console": [
+    {
+      "type": "error",
+      "text": "Failed to load resource: the server responded with a status of 404 (Not Found)",
+      "location": {
+        "url": "http://localhost:8080/favicon.ico",
+        "line": 0,
+        "column": 0,
+        "lineNumber": 0,
+        "columnNumber": 0
+      }
+    }
+  ]
+}
diff --git a/notes/mcp-apps/experiments/26/results/reference-host.log b/notes/mcp-apps/experiments/26/results/reference-host.log
new file mode 100644
index 000000000..045bfdd35
--- /dev/null
+++ b/notes/mcp-apps/experiments/26/results/reference-host.log
@@ -0,0 +1,4 @@
+Host server:    http://localhost:8080
+Sandbox server: http://localhost:8081
+
+Press Ctrl+C to stop
diff --git a/notes/mcp-apps/experiments/27/README.md b/notes/mcp-apps/experiments/27/README.md
new file mode 100644
index 000000000..5aa41b85c
--- /dev/null
+++ b/notes/mcp-apps/experiments/27/README.md
@@ -0,0 +1,31 @@
+# Experiment 27: Stable fullscreen control and Axe detail
+
+## Purpose
+
+Preserve the host's display-mode capabilities across partial context updates and
+identify the exact node behind the one remaining Axe finding.
+
+**Changes from experiment 26:**
+
+- Update fullscreen availability only when a host-context notification actually
+  carries `availableDisplayModes`.
+- Include Axe's full violation report in the recorded result.
+- Keep the server, cookie, fixture, modes, and keyboard interaction unchanged.
+
+**Expected outcomes:**
+
+- `Return inline` remains visible after the resize-only host notification.
+- Fullscreen and durable-event evidence stay green.
+- The Axe report names the concrete element requiring either a fix or an
+  evidence-backed explanation.
+
+## Findings
+
+The fullscreen fix held: after the host's resize-only context notification,
+`Return inline` remained visible. Full rendering, dimensions, and the durable
+keyboard event stayed green.
+
+Axe's one moderate finding targets `.lf-banner-status`: the injected status text
+is outside a landmark because the top Leaf chrome is a generic `div`.
+Experiment 28 changes that existing top chrome to a semantic `header` and checks
+the composed app again.
diff --git a/notes/mcp-apps/experiments/27/commands.sh b/notes/mcp-apps/experiments/27/commands.sh
new file mode 100644
index 000000000..d17ae06be
--- /dev/null
+++ b/notes/mcp-apps/experiments/27/commands.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+repo="$(git rev-parse --show-toplevel)"
+cd "$repo"
+
+results="$repo/notes/mcp-apps/experiments/27/results"
+run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-27.XXXXXX")"
+reference="/tmp/leaf-mcp-apps-reference-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
+
+mkdir -p "$results"
+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"
+shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256"
+
+test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720"
+(cd "$reference" && npm 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"]' bun --watch 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/27/observe.py "$run_dir/page" \
+  >"$results/reference-host.json"
diff --git a/notes/mcp-apps/experiments/27/observe.py b/notes/mcp-apps/experiments/27/observe.py
new file mode 100755
index 000000000..0b3a14689
--- /dev/null
+++ b/notes/mcp-apps/experiments/27/observe.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+"""Verify stable fullscreen controls and capture complete Axe detail."""
+
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+HERE = Path(__file__).parent
+source = HERE.parent / "16/observe.py"
+spec = spec_from_file_location("mcp_apps_experiment_16_observe", source)
+experiment = module_from_spec(spec)
+spec.loader.exec_module(experiment)
+experiment.HERE = HERE
+
+
+if __name__ == "__main__":
+    experiment.main(
+        tool_timeout=60_000,
+        wait_for_options=False,
+        use_dom_gate=True,
+        body_call_record=True,
+        tolerate_mode_control=True,
+    )
diff --git a/notes/mcp-apps/experiments/27/results/bundle.sha256 b/notes/mcp-apps/experiments/27/results/bundle.sha256
new file mode 100644
index 000000000..78fc104de
--- /dev/null
+++ b/notes/mcp-apps/experiments/27/results/bundle.sha256
@@ -0,0 +1 @@
+35ad0d03b91e8903e7132e8916172f78a2b9226389e336addca8d2f53b23963f  skills/leaf/assets/vendor/mcp-page-app.html
diff --git a/notes/mcp-apps/experiments/27/results/mcp-server.log b/notes/mcp-apps/experiments/27/results/mcp-server.log
new file mode 100644
index 000000000..0096fd929
--- /dev/null
+++ b/notes/mcp-apps/experiments/27/results/mcp-server.log
@@ -0,0 +1,2 @@
+StreamableHTTP session manager started
+Created new transport with session ID: a52aa56a87314c6ba61cb5a696e22146
diff --git a/notes/mcp-apps/experiments/27/results/reference-host.json b/notes/mcp-apps/experiments/27/results/reference-host.json
new file mode 100644
index 000000000..1695647c7
--- /dev/null
+++ b/notes/mcp-apps/experiments/27/results/reference-host.json
@@ -0,0 +1,111 @@
+{
+  "reference_host": {
+    "repo": "https://github.com/modelcontextprotocol/ext-apps",
+    "commit": "10195ad91851502134930e9b80ec2c04e277a720"
+  },
+  "invocation": {
+    "selected_tool": "leaf_open_page",
+    "initial_input": "{}",
+    "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-27.5iVu9W/page\"}",
+    "rendered_input": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-27.5iVu9W/page"
+  },
+  "inline": {
+    "viewport": {
+      "width": 1060,
+      "height": 332
+    },
+    "document": {
+      "client_width": 1060,
+      "client_height": 332,
+      "scroll_width": 1060,
+      "scroll_height": 2857
+    },
+    "stamps": {
+      "upgraded": "1",
+      "applied": "0",
+      "presented": "1"
+    },
+    "headings": [
+      "Where sessions live",
+      "Constraints",
+      "Settled: the session id travels in a host-only Lax cookie",
+      "The payments precedent is useful — and not equivalent",
+      "Where should a session live?",
+      "The two cookies"
+    ],
+    "widgets": [
+      "lf-gloss",
+      "lf-options",
+      "lf-compare",
+      "lf-options",
+      "lf-compare"
+    ]
+  },
+  "fullscreen": {
+    "viewport": {
+      "width": 1068,
+      "height": 806
+    },
+    "document": {
+      "client_width": 1068,
+      "client_height": 806,
+      "scroll_width": 1068,
+      "scroll_height": 2857
+    },
+    "stamps": {
+      "upgraded": "1",
+      "applied": "0",
+      "presented": "1"
+    },
+    "headings": [
+      "Where sessions live",
+      "Constraints",
+      "Settled: the session id travels in a host-only Lax cookie",
+      "The payments precedent is useful — and not equivalent",
+      "Where should a session live?",
+      "The two cookies"
+    ],
+    "widgets": [
+      "lf-gloss",
+      "lf-options",
+      "lf-compare",
+      "lf-options",
+      "lf-compare"
+    ]
+  },
+  "mode_control": {
+    "return_visible": true
+  },
+  "axe": {
+    "violations": 1,
+    "snapshot": "region (moderate) : 1",
+    "report": "Found 1 accessibility violations:\nRule Violated:\nregion - Ensure all page content is contained by landmarks\n\tURL: https://dequeuniversity.com/rules/axe/4.12/region?application=axeAPI\n\tImpact Level: moderate\n\tTags: ['cat.keyboard', 'best-practice', 'RGAAv4', 'RGAA-9.2.1']\n\tElements Affected:\n\n\n\t1)\tTarget: .lf-banner-status\n\t\tSnippet: 
Claude is working — Writing the page (just now)
\n\t\tMessages:\n\t\t* Some page content is not contained by landmarks\n" + }, + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 1 + }, + "screenshots": { + "inline": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/27/results/full-page-inline.png", + "fullscreen": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/27/results/full-page-fullscreen.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/27/results/reference-host.log b/notes/mcp-apps/experiments/27/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/27/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/28/README.md b/notes/mcp-apps/experiments/28/README.md new file mode 100644 index 000000000..1b3517cea --- /dev/null +++ b/notes/mcp-apps/experiments/28/README.md @@ -0,0 +1,26 @@ +# Experiment 28: Landmark-clean complete Leaf page + +## Purpose + +Resolve the exact Axe finding by giving Leaf's existing top chrome its native +header landmark, then repeat the composed app measurement. + +**Changes from experiment 27:** + +- Create `.lf-banner` as `
` instead of `
`. +- Add a browser regression assertion for that semantic element. +- Change no layout classes, contents, controls, app transport, or interaction. + +**Expected outcomes:** + +- Axe reports no violations in the complete nested Leaf frame. +- Inline/fullscreen dimensions and horizontal overflow remain unchanged. +- Fullscreen return and the durable Redis keyboard choice remain green. + +## Findings + +The composed page passed unchanged layout and interaction evidence with the new +header landmark. Inline remained 1060×332 and fullscreen 1068×806 over the same +2,857px document, with no horizontal overflow. `Return inline` stayed visible, +Axe reported zero violations, and the Redis keyboard choice again appended the +ordinary sequence-1 action to the page log. diff --git a/notes/mcp-apps/experiments/28/commands.sh b/notes/mcp-apps/experiments/28/commands.sh new file mode 100644 index 000000000..e05e7c816 --- /dev/null +++ b/notes/mcp-apps/experiments/28/commands.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/28/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-28.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +uv run pytest -n0 tests/test_mcp_app.py +uv run pytest -n0 tests/test_render_controls.py \ + -k test_a_page_that_asks_nothing_carries_no_terminal_control + +./bin/leaf page init "$run_dir/page" +cp examples/design-decision.html "$run_dir/page/index.html" +./bin/leaf version check "$run_dir/page" +shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/28/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/28/observe.py b/notes/mcp-apps/experiments/28/observe.py new file mode 100755 index 000000000..cd4e51ca9 --- /dev/null +++ b/notes/mcp-apps/experiments/28/observe.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Verify the complete Leaf app after adding its banner landmark.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "16/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_16_observe", source) +experiment = module_from_spec(spec) +spec.loader.exec_module(experiment) +experiment.HERE = HERE + + +if __name__ == "__main__": + experiment.main( + tool_timeout=60_000, + wait_for_options=False, + use_dom_gate=True, + body_call_record=True, + tolerate_mode_control=True, + ) diff --git a/notes/mcp-apps/experiments/28/results/bundle.sha256 b/notes/mcp-apps/experiments/28/results/bundle.sha256 new file mode 100644 index 000000000..78fc104de --- /dev/null +++ b/notes/mcp-apps/experiments/28/results/bundle.sha256 @@ -0,0 +1 @@ +35ad0d03b91e8903e7132e8916172f78a2b9226389e336addca8d2f53b23963f skills/leaf/assets/vendor/mcp-page-app.html diff --git a/notes/mcp-apps/experiments/28/results/mcp-server.log b/notes/mcp-apps/experiments/28/results/mcp-server.log new file mode 100644 index 000000000..33fd2f37d --- /dev/null +++ b/notes/mcp-apps/experiments/28/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: 0d5e70e39d8b47c4b8f2a324a365295c diff --git a/notes/mcp-apps/experiments/28/results/reference-host.json b/notes/mcp-apps/experiments/28/results/reference-host.json new file mode 100644 index 000000000..1f25c8be7 --- /dev/null +++ b/notes/mcp-apps/experiments/28/results/reference-host.json @@ -0,0 +1,111 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "invocation": { + "selected_tool": "leaf_open_page", + "initial_input": "{}", + "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-28.eihQQL/page\"}", + "rendered_input": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-28.eihQQL/page" + }, + "inline": { + "viewport": { + "width": 1060, + "height": 332 + }, + "document": { + "client_width": 1060, + "client_height": 332, + "scroll_width": 1060, + "scroll_height": 2857 + }, + "stamps": { + "upgraded": "1", + "applied": "0", + "presented": "1" + }, + "headings": [ + "Where sessions live", + "Constraints", + "Settled: the session id travels in a host-only Lax cookie", + "The payments precedent is useful — and not equivalent", + "Where should a session live?", + "The two cookies" + ], + "widgets": [ + "lf-gloss", + "lf-options", + "lf-compare", + "lf-options", + "lf-compare" + ] + }, + "fullscreen": { + "viewport": { + "width": 1068, + "height": 806 + }, + "document": { + "client_width": 1068, + "client_height": 806, + "scroll_width": 1068, + "scroll_height": 2857 + }, + "stamps": { + "upgraded": "1", + "applied": "0", + "presented": "1" + }, + "headings": [ + "Where sessions live", + "Constraints", + "Settled: the session id travels in a host-only Lax cookie", + "The payments precedent is useful — and not equivalent", + "Where should a session live?", + "The two cookies" + ], + "widgets": [ + "lf-gloss", + "lf-options", + "lf-compare", + "lf-options", + "lf-compare" + ] + }, + "mode_control": { + "return_visible": true + }, + "axe": { + "violations": 0, + "snapshot": "", + "report": "Found 0 accessibility violations:\n" + }, + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 1 + }, + "screenshots": { + "inline": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/28/results/full-page-inline.png", + "fullscreen": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/28/results/full-page-fullscreen.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/28/results/reference-host.log b/notes/mcp-apps/experiments/28/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/28/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/29/README.md b/notes/mcp-apps/experiments/29/README.md new file mode 100644 index 000000000..cbb8e74f3 --- /dev/null +++ b/notes/mcp-apps/experiments/29/README.md @@ -0,0 +1,33 @@ +# Experiment 29: Comments and version travel in the full app + +## Purpose + +Exercise the two page-level systems most likely to expose a false “full Leaf” +claim: anchored comment creation and navigation between immutable versions. + +**Changes from experiment 28:** + +- Give the decision page two stamped versions whose opening sentence differs. +- Create an anchored comment on text shared by both versions from inside the MCP + App. +- Navigate v2 → v1 → v2 through Leaf's own version menu and measure the anchor + highlight on both. + +**Expected outcomes:** + +- The comment appends through `/api/event` with its ordinary passage anchor. +- Both historical navigations remain authorized by the partitioned session. +- The thread quote and highlight resolve on v1 and v2, while the surrounding + changed sentence proves the document actually traveled. + +## Findings + +The composed app authored the normal sequence-4 comment at revision 2 with +Leaf's `{section, quote, prefix, suffix}` anchor. Its quote and one painted +highlight remained resolved after navigating to +`/versions/v1.html?pin=` and back to `/versions/v2.html?pin=`, while the changed +`split`/`extraction` sentence proved the document actually changed. + +The earlier full-page evidence remained green: both display modes, zero Axe +violations, the visible return control, and the Redis action persisted alongside +the comment and version travel. diff --git a/notes/mcp-apps/experiments/29/commands.sh b/notes/mcp-apps/experiments/29/commands.sh new file mode 100644 index 000000000..990f3294f --- /dev/null +++ b/notes/mcp-apps/experiments/29/commands.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/29/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-29.XXXXXX")" +page_dir="$run_dir/page" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +uv run pytest -n0 tests/test_mcp_app.py + +./bin/leaf page init "$page_dir" +cp examples/design-decision.html "$page_dir/index.html" +./bin/leaf version stamp --text "Baseline decision" "$page_dir" +perl -0pi -e 's/The monolith split leaves/The monolith extraction leaves/' \ + "$page_dir/index.html" +./bin/leaf version stamp --text "Name the extraction" "$page_dir" +shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/29/observe.py "$page_dir" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/29/observe.py b/notes/mcp-apps/experiments/29/observe.py new file mode 100755 index 000000000..adc5b2de6 --- /dev/null +++ b/notes/mcp-apps/experiments/29/observe.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Exercise comments and immutable-version travel in the complete app.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "16/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_16_observe", source) +experiment = module_from_spec(spec) +spec.loader.exec_module(experiment) +experiment.HERE = HERE + + +if __name__ == "__main__": + experiment.main( + tool_timeout=60_000, + wait_for_options=False, + use_dom_gate=True, + body_call_record=True, + tolerate_mode_control=True, + exercise_comments_versions=True, + ) diff --git a/notes/mcp-apps/experiments/29/results/bundle.sha256 b/notes/mcp-apps/experiments/29/results/bundle.sha256 new file mode 100644 index 000000000..78fc104de --- /dev/null +++ b/notes/mcp-apps/experiments/29/results/bundle.sha256 @@ -0,0 +1 @@ +35ad0d03b91e8903e7132e8916172f78a2b9226389e336addca8d2f53b23963f skills/leaf/assets/vendor/mcp-page-app.html diff --git a/notes/mcp-apps/experiments/29/results/mcp-server.log b/notes/mcp-apps/experiments/29/results/mcp-server.log new file mode 100644 index 000000000..879373e14 --- /dev/null +++ b/notes/mcp-apps/experiments/29/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: 1482580b6a7443c898183c9368c1bf9b diff --git a/notes/mcp-apps/experiments/29/results/reference-host.json b/notes/mcp-apps/experiments/29/results/reference-host.json new file mode 100644 index 000000000..930d13a5f --- /dev/null +++ b/notes/mcp-apps/experiments/29/results/reference-host.json @@ -0,0 +1,131 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "invocation": { + "selected_tool": "leaf_open_page", + "initial_input": "{}", + "submitted_input": "{\"page\": \"/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-29.tTS2f5/page\"}", + "rendered_input": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-29.tTS2f5/page" + }, + "inline": { + "viewport": { + "width": 1060, + "height": 332 + }, + "document": { + "client_width": 1060, + "client_height": 332, + "scroll_width": 1060, + "scroll_height": 2857 + }, + "stamps": { + "upgraded": "1", + "applied": "0", + "presented": "1" + }, + "headings": [ + "Where sessions live", + "Constraints", + "Settled: the session id travels in a host-only Lax cookie", + "The payments precedent is useful — and not equivalent", + "Where should a session live?", + "The two cookies" + ], + "widgets": [ + "lf-gloss", + "lf-options", + "lf-compare", + "lf-options", + "lf-compare" + ] + }, + "fullscreen": { + "viewport": { + "width": 1068, + "height": 806 + }, + "document": { + "client_width": 1068, + "client_height": 806, + "scroll_width": 1068, + "scroll_height": 2857 + }, + "stamps": { + "upgraded": "1", + "applied": "0", + "presented": "1" + }, + "headings": [ + "Where sessions live", + "Constraints", + "Settled: the session id travels in a host-only Lax cookie", + "The payments precedent is useful — and not equivalent", + "Where should a session live?", + "The two cookies" + ], + "widgets": [ + "lf-gloss", + "lf-options", + "lf-compare", + "lf-options", + "lf-compare" + ] + }, + "mode_control": { + "return_visible": true + }, + "axe": { + "violations": 0, + "snapshot": "", + "report": "Found 0 accessibility violations:\n" + }, + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 3 + }, + "complete_interface": { + "comment": { + "kind": "comment", + "text": "Keep this rationale across versions.", + "revision": 2, + "anchor": { + "section": "decision-lede", + "quote": "session state homeless: today it rides the app", + "prefix": "nolith extraction leaves", + "suffix": "server's memory and dies" + }, + "seq": 4 + }, + "version_travel": { + "v1_url": "http://localhost:55518/versions/v1.html?pin=", + "v2_url": "http://localhost:55518/versions/v2.html?pin=", + "marked_v1": 1, + "marked_v2": 1 + } + }, + "screenshots": { + "inline": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/29/results/full-page-inline.png", + "fullscreen": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/29/results/full-page-fullscreen.png" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ] +} diff --git a/notes/mcp-apps/experiments/29/results/reference-host.log b/notes/mcp-apps/experiments/29/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/29/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/3/README.md b/notes/mcp-apps/experiments/3/README.md new file mode 100644 index 000000000..4ab64fc1c --- /dev/null +++ b/notes/mcp-apps/experiments/3/README.md @@ -0,0 +1,30 @@ +# Experiment 3: Wait for the host's tool selection + +## Purpose + +Run the same compact ask after making the browser driver wait until the official host has selected `leaf_open_page` and populated its default `{}` input. + +**Changes from experiment 2:** + +- Wait for the Tool select to hold `leaf_open_page` and the Input textarea to hold `{}` before filling the page path. +- Raise the browser assertion timeout to 15 seconds for the nested sandbox handshake. + +**Configuration:** + +- Transport: streamable HTTP adaptation of the shipped stdio MCP server +- UI: disposable `ui://` HTML resource +- Return path: `callServerTool` → Leaf `EventEndpoint` +- Supported ask: one current page-scoped, single-choice `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 a keyboard choice appends `choose` and teardown/reopen returns an empty ask, the app has no second state authority. +- If the 420×360 iframe has no horizontal overflow and exposes three short option labels, the compact projection is viable for the shipped substantial decision. +- If the host console is clean and the app never contacts a Leaf page server, the bundled resource and app-only tools are sufficient. + +## Findings + +The readiness wait worked: the reference host passed the page argument and Leaf accepted `leaf_open_page`. The app did not load because the experiment launched the host's TypeScript server with `tsx`; in this mode `import.meta.url` was undefined, so the host derived the wrong `dist/` path and returned 404 for its own sandbox HTML. A direct request reproduced the 404 while the built file existed. The host's package script specifies Bun, so experiment 4 changes only that runner. diff --git a/notes/mcp-apps/experiments/3/commands.sh b/notes/mcp-apps/experiments/3/commands.sh new file mode 100755 index 000000000..8d5ca23ba --- /dev/null +++ b/notes/mcp-apps/experiments/3/commands.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/3/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-3.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 + +mkdir -p "$results" +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/3/observe.py "$run_dir/page" \ + >"$results/reference-host.json" + +# Open the generated compact-app screenshot: +# open notes/mcp-apps/experiments/3/results/compact-ask-420x360.png diff --git a/notes/mcp-apps/experiments/3/observe.py b/notes/mcp-apps/experiments/3/observe.py new file mode 100755 index 000000000..2cc85248f --- /dev/null +++ b/notes/mcp-apps/experiments/3/observe.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Drive experiment 3 after the reference host's form has stabilized.""" + +import json +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +from leaf.event_log import read_events +from playwright.sync_api import expect, sync_playwright + +HERE = Path(__file__).parent +source = HERE.parent / "1/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_1_observe", source) +driver = module_from_spec(spec) +spec.loader.exec_module(driver) +driver.HERE = HERE + + +def main() -> None: + page_dir = Path(sys.argv[1]).resolve() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel="chrome", headless=True) + page = browser.new_page(viewport={"width": 720, "height": 920}) + page.set_default_timeout(15_000) + console = [] + page.on( + "console", + lambda message: ( + console.append({"type": message.type, "text": message.text}) + if message.type in {"error", "warning"} + else None + ), + ) + page.goto(driver.HOST) + expect(page.locator("select").nth(1)).to_have_value("leaf_open_page") + tool_input = page.locator("textarea") + expect(tool_input).to_have_value("{}") + tool_input.fill(json.dumps({"page": str(page_dir)})) + page.get_by_role("button", name="Call Tool").click() + outer, frame = driver.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 = driver.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 = driver.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 = driver.app_frame(page) + expect( + frame.get_by_role("heading", name="Nothing waiting here") + ).to_be_visible() + reopened = driver.measure(frame) + + print( + json.dumps( + { + "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), + }, + indent=2, + ensure_ascii=False, + ) + ) + browser.close() + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/3/output.txt b/notes/mcp-apps/experiments/3/output.txt new file mode 100644 index 000000000..d6b8c95bc --- /dev/null +++ b/notes/mcp-apps/experiments/3/output.txt @@ -0,0 +1,59 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 7 items + +tests/test_mcp_app.py ....... [100%] + +============================== 7 passed in 2.57s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-3.PGIIDo/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column +Traceback (most recent call last): + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/3/observe.py", line 98, in + main() + ~~~~^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/3/observe.py", line 41, in main + outer, frame = driver.app_frame(page) + ~~~~~~~~~~~~~~~~^^^^^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/1/observe.py", line 27, in app_frame + expect(sandbox.locator("iframe")).to_have_count(1) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/sync_api/_generated.py", line 22365, in to_have_count + self._sync( + ~~~~~~~~~~^ + self._impl_obj.to_have_count( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + count=count, timeout=to_milliseconds(timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + ) + ^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_sync_base.py", line 115, in _sync + return task.result() + ~~~~~~~~~~~^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 465, in to_have_count + await self._expect_impl( + ...<5 lines>... + ) + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 128, in _expect_impl + _record_soft_or_raise( + ~~~~~~~~~~~~~~~~~~~~~^ + AssertionError( + ^^^^^^^^^^^^^^^ + ...<2 lines>... + self._is_soft, + ^^^^^^^^^^^^^^ + ) + ^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 60, in _record_soft_or_raise + raise error +AssertionError: Locator expected to have count '1' +Actual value: 0 +Call log: + - Expect "to_have_count" with timeout 5000ms + - waiting for locator("iframe").content_frame.locator("iframe") + 14 × locator resolved to 0 elements + - unexpected value "0" diff --git a/notes/mcp-apps/experiments/3/results/mcp-server.log b/notes/mcp-apps/experiments/3/results/mcp-server.log new file mode 100644 index 000000000..e3666b590 --- /dev/null +++ b/notes/mcp-apps/experiments/3/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: b27eabbe472742979cd6e8eb34f78773 diff --git a/notes/mcp-apps/experiments/3/results/reference-host.json b/notes/mcp-apps/experiments/3/results/reference-host.json new file mode 100644 index 000000000..19765bd50 --- /dev/null +++ b/notes/mcp-apps/experiments/3/results/reference-host.json @@ -0,0 +1 @@ +null diff --git a/notes/mcp-apps/experiments/3/results/reference-host.log b/notes/mcp-apps/experiments/3/results/reference-host.log new file mode 100644 index 000000000..ffac1f3d7 --- /dev/null +++ b/notes/mcp-apps/experiments/3/results/reference-host.log @@ -0,0 +1,18 @@ +(node:74906) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead. +(Use `node --trace-deprecation ...` to show where the warning was created) +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop + +NotFoundError: Not Found + at createHttpError (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/send/index.js:861:12) + at SendStream.error (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/send/index.js:168:31) + at SendStream.pipe (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/send/index.js:468:14) + at sendfile (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/express/lib/response.js:1014:8) + at ServerResponse.sendFile (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/express/lib/response.js:411:3) + at (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/examples/basic-host/serve.ts:127:7) + at Layer.handleRequest (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/layer.js:152:17) + at next (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/route.js:157:13) + at Route.dispatch (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/route.js:117:3) + at handle (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/index.js:435:11) diff --git a/notes/mcp-apps/experiments/30/README.md b/notes/mcp-apps/experiments/30/README.md new file mode 100644 index 000000000..60b1ff19b --- /dev/null +++ b/notes/mcp-apps/experiments/30/README.md @@ -0,0 +1,50 @@ +# Experiment 30: One exact origin with page capability paths + +## Purpose + +Test the production candidate for delivering the complete canonical Leaf page: +one loopback origin registered exactly in MCP Apps CSP, with each page isolated +under an unguessable path and no query credential or cookie. + +**Changes from experiment 29:** + +- Replace one wildcard-CSP HTTP server and partitioned cookie per page with one + process-scoped exact origin and `/p//` routes. +- Exercise the auto-registered `leaf_present` tool rather than the earlier + `leaf_open_page` prototype. +- Remove the fixed single-choice compact MCP interface and keep the authored + snapshot only as an explicit fallback. + +**Configuration:** + +- MCP Apps reference host: `modelcontextprotocol/ext-apps` at + `10195ad91851502134930e9b80ec2c04e277a720`. +- Resource CSP: the ephemeral `http://localhost:` origin exactly. +- Page address: queryless random capability path on that origin. + +**Expected outcomes:** + +- If the exact origin is sufficient, the nested frame reaches Leaf's presented + state and a keyboard option action appends through the ordinary event endpoint. +- If the host rejects the origin or nested local frame, the outer app appears but + the canonical page never reaches its presentation gate. +- Any request escaping the capability path or any Leaf-origin console failure is + a routing defect. + +## Findings + +The exact-origin design passed in the pinned official reference host. The +resource declared one concrete `http://localhost:59857` frame domain and the +nested canonical page loaded from the same origin at the queryless random path +`/p/RjvO7cTim42U_745ZdE9Zk24azyZGz93/`. It reached +`data-lf-presented="1"`, negotiated fullscreen with a visible return control, +and a keyboard Redis choice appended the ordinary sequence-2 `choose` action to +`session-options`. + +No warning or error came from the app or Leaf origins. The one console error was +the reference host's own missing `http://localhost:8080/favicon.ico`. Axe found +one moderate `region` violation when measured after the choice; experiment 29's +zero-violation measurement ran before its choice, so this result does not isolate +transport from interaction state and is not evidence against the exact-origin +route. Stratify that timing separately if accessibility parity becomes the next +question. diff --git a/notes/mcp-apps/experiments/30/commands.sh b/notes/mcp-apps/experiments/30/commands.sh new file mode 100644 index 000000000..ab847d4cb --- /dev/null +++ b/notes/mcp-apps/experiments/30/commands.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/30/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-30.XXXXXX")" +page_dir="$run_dir/page" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +uv run pytest -n0 tests/test_mcp_app.py tests/test_interact_mcp.py tests/test_render_mcp.py + +./bin/leaf page init "$page_dir" +cp examples/design-decision.html "$page_dir/index.html" +./bin/leaf version stamp "$page_dir" --text "Exact-origin MCP page" +shasum -a 256 skills/leaf/assets/vendor/mcp-page-app.html >"$results/bundle.sha256" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/30/observe.py "$page_dir" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/30/observe.py b/notes/mcp-apps/experiments/30/observe.py new file mode 100755 index 000000000..c302ba4db --- /dev/null +++ b/notes/mcp-apps/experiments/30/observe.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Exercise one exact CSP origin and its canonical Leaf capability path.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +from axe_playwright_python.sync_playwright import Axe +from leaf.event_log import read_events +from playwright.sync_api import expect, sync_playwright + +HERE = Path(__file__).parent +HOST = "http://localhost:8080/?tool=leaf_present" + + +def main() -> None: + page_dir = Path(sys.argv[1]).resolve() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel="chrome", headless=True) + page = browser.new_page(viewport={"width": 1100, "height": 900}) + page.set_default_timeout(60_000) + console = [] + page.on( + "console", + lambda message: ( + console.append( + { + "type": message.type, + "text": message.text, + "url": message.location.get("url"), + } + ) + if message.type in {"error", "warning"} + else None + ), + ) + + page.goto(HOST) + page.wait_for_function( + "() => document.querySelectorAll('select')[1]?.value === 'leaf_present'" + ) + page.locator("textarea").fill(json.dumps({"page": str(page_dir)})) + page.get_by_role("button", name="Call Tool").click() + + expect(page.locator("iframe")).to_have_count(1) + sandbox = page.frame_locator("iframe") + expect(sandbox.locator("iframe")).to_have_count(1) + app = sandbox.frame_locator("iframe") + expect(app.locator("#leaf-page")).to_have_count(1) + leaf = app.frame_locator("#leaf-page") + expect(leaf.locator("body")).to_have_attribute("data-lf-presented", "1") + expect(leaf.get_by_role("heading", name="Where sessions live")).to_be_visible() + + sandbox_url = page.frames[1].url + csp = json.loads(parse_qs(urlsplit(sandbox_url).query)["csp"][0]) + inner_url = page.frames[-1].url + inner = urlsplit(inner_url) + exact_origin = f"{inner.scheme}://{inner.netloc}" + assert csp["frameDomains"] == [exact_origin] + assert inner.path.startswith("/p/") and inner.path.endswith("/") + assert not inner.query + + option = leaf.locator("#opt-redis").get_by_role("checkbox") + option.focus() + option.press("Space") + expect(leaf.locator("#opt-redis")).to_have_attribute("chosen", "") + action = read_events(page_dir)[-1] + + inline_shot = HERE / "results/exact-origin-inline.png" + page.locator("iframe").screenshot(path=inline_shot) + app.get_by_role("button", name="Full screen").click() + page.wait_for_timeout(500) + fullscreen_shot = HERE / "results/exact-origin-fullscreen.png" + page.screenshot(path=fullscreen_shot, full_page=True) + axe = Axe().run(page.frames[-1]) + + leaf_console = [ + message + for message in console + if not ( + message["url"] == "http://localhost:8080/favicon.ico" + and "404" in message["text"] + ) + ] + assert leaf_console == [] + result = { + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720", + }, + "tool": page.locator("select").nth(1).input_value(), + "resource_csp": csp, + "inner_url": inner_url, + "queryless_capability_path": True, + "presented": leaf.locator("body").get_attribute("data-lf-presented"), + "keyboard_choice": { + "widget": action["widget"], + "action": action["action"], + "detail": action["detail"], + "seq": action["seq"], + }, + "mode_control": { + "return_inline_visible": app.get_by_role( + "button", name="Return inline" + ).is_visible() + }, + "axe": { + "violations": axe.violations_count, + "snapshot": axe.generate_snapshot(), + }, + "console": console, + "leaf_console": leaf_console, + "screenshots": { + "inline": str(inline_shot), + "fullscreen": str(fullscreen_shot), + }, + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + browser.close() + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/30/output.txt b/notes/mcp-apps/experiments/30/output.txt new file mode 100644 index 000000000..8c1538e7a --- /dev/null +++ b/notes/mcp-apps/experiments/30/output.txt @@ -0,0 +1,14 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 14 items + +tests/test_mcp_app.py .... [ 28%] +tests/test_interact_mcp.py ...... [ 71%] +tests/test_render_mcp.py .... [100%] + +============================= 14 passed in 13.69s ============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-30.t5wNns/page +{"kind": "note", "author": "claude", "agent": "Codex", "session": "01a054d1-f145-7bd3-979a-69e45dd5980d", "version": 1, "revision": 1, "text": "Exact-origin MCP page", "id": "5351617f764faf9aaa15889caa6c93c7", "ts": "2026-09-01T00:07:39-07:00"} diff --git a/notes/mcp-apps/experiments/30/results/bundle.sha256 b/notes/mcp-apps/experiments/30/results/bundle.sha256 new file mode 100644 index 000000000..5cd3a751b --- /dev/null +++ b/notes/mcp-apps/experiments/30/results/bundle.sha256 @@ -0,0 +1 @@ +bbe2efc3e8be14b3f1f75970cf1c8d85d0512ce8a76559646e99c428c6afef10 skills/leaf/assets/vendor/mcp-page-app.html diff --git a/notes/mcp-apps/experiments/30/results/mcp-server.log b/notes/mcp-apps/experiments/30/results/mcp-server.log new file mode 100644 index 000000000..66aa84009 --- /dev/null +++ b/notes/mcp-apps/experiments/30/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: bdedbae2ba3e4f3ab4276a484355d433 diff --git a/notes/mcp-apps/experiments/30/results/reference-host.json b/notes/mcp-apps/experiments/30/results/reference-host.json new file mode 100644 index 000000000..3e388bd90 --- /dev/null +++ b/notes/mcp-apps/experiments/30/results/reference-host.json @@ -0,0 +1,44 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "tool": "leaf_present", + "resource_csp": { + "frameDomains": [ + "http://localhost:59857" + ] + }, + "inner_url": "http://localhost:59857/p/RjvO7cTim42U_745ZdE9Zk24azyZGz93/", + "queryless_capability_path": true, + "presented": "1", + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 2 + }, + "mode_control": { + "return_inline_visible": true + }, + "axe": { + "violations": 1, + "snapshot": "region (moderate) : 1" + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "url": "http://localhost:8080/favicon.ico" + } + ], + "leaf_console": [], + "screenshots": { + "inline": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/30/results/exact-origin-inline.png", + "fullscreen": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/30/results/exact-origin-fullscreen.png" + } +} diff --git a/notes/mcp-apps/experiments/30/results/reference-host.log b/notes/mcp-apps/experiments/30/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/30/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/4/README.md b/notes/mcp-apps/experiments/4/README.md new file mode 100644 index 000000000..9b13df91e --- /dev/null +++ b/notes/mcp-apps/experiments/4/README.md @@ -0,0 +1,29 @@ +# Experiment 4: Use the reference host's own runner + +## Purpose + +Run the same compact ask with Bun, the runner declared by the pinned reference host, so its sandbox server resolves `import.meta.url` and serves the built iframe proxy. + +**Changes from experiment 3:** + +- Launch `serve.ts` with `bun --watch`, matching the official package script, instead of `tsx`. + +**Configuration:** + +- Transport: streamable HTTP adaptation of the shipped stdio MCP server +- UI: disposable `ui://` HTML resource +- Return path: `callServerTool` → Leaf `EventEndpoint` +- Supported ask: one current page-scoped, single-choice `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 a keyboard choice appends `choose` and teardown/reopen returns an empty ask, the app has no second state authority. +- If the 420×360 iframe has no horizontal overflow and exposes three short option labels, the compact projection is viable for the shipped substantial decision. +- If the host console is clean and the app never contacts a Leaf page server, the bundled resource and app-only tools are sufficient. + +## Findings + +The Bun runner resolved the host source correctly, but Express's sandbox `sendFile` still returned 404 because its absolute path crossed Leaf's hidden `.tmp/` directory; the `send` package ignores dot-paths by default. A direct request against the identical pinned clone under `/tmp` returned the sandbox with status 200 and byte-for-byte equality. Leaf accepted the open call and appended nothing. Experiment 5 changes only the clone location. diff --git a/notes/mcp-apps/experiments/4/commands.sh b/notes/mcp-apps/experiments/4/commands.sh new file mode 100755 index 000000000..a57af34f5 --- /dev/null +++ b/notes/mcp-apps/experiments/4/commands.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/4/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-4.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 + +mkdir -p "$results" +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"]' bun --watch 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/4/observe.py "$run_dir/page" \ + >"$results/reference-host.json" + +# Open the generated compact-app screenshot: +# open notes/mcp-apps/experiments/4/results/compact-ask-420x360.png diff --git a/notes/mcp-apps/experiments/4/observe.py b/notes/mcp-apps/experiments/4/observe.py new file mode 100755 index 000000000..f89e1ead9 --- /dev/null +++ b/notes/mcp-apps/experiments/4/observe.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Run experiment 3's browser driver, writing into experiment 4.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "3/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_3_observe", source) +driver = module_from_spec(spec) +spec.loader.exec_module(driver) +driver.HERE = HERE +driver.driver.HERE = HERE +driver.main() diff --git a/notes/mcp-apps/experiments/4/output.txt b/notes/mcp-apps/experiments/4/output.txt new file mode 100644 index 000000000..c09e536a7 --- /dev/null +++ b/notes/mcp-apps/experiments/4/output.txt @@ -0,0 +1,59 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 7 items + +tests/test_mcp_app.py ....... [100%] + +============================== 7 passed in 2.58s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-4.qpFc1w/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column +Traceback (most recent call last): + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/4/observe.py", line 14, in + driver.main() + ~~~~~~~~~~~^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/3/observe.py", line 41, in main + outer, frame = driver.app_frame(page) + ~~~~~~~~~~~~~~~~^^^^^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/1/observe.py", line 27, in app_frame + expect(sandbox.locator("iframe")).to_have_count(1) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/sync_api/_generated.py", line 22365, in to_have_count + self._sync( + ~~~~~~~~~~^ + self._impl_obj.to_have_count( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + count=count, timeout=to_milliseconds(timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + ) + ^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_sync_base.py", line 115, in _sync + return task.result() + ~~~~~~~~~~~^^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 465, in to_have_count + await self._expect_impl( + ...<5 lines>... + ) + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 128, in _expect_impl + _record_soft_or_raise( + ~~~~~~~~~~~~~~~~~~~~~^ + AssertionError( + ^^^^^^^^^^^^^^^ + ...<2 lines>... + self._is_soft, + ^^^^^^^^^^^^^^ + ) + ^ + File "/Users/maximilian/.codex/worktrees/a5c8/leaf/.venv/lib/python3.14/site-packages/playwright/_impl/_assertions.py", line 60, in _record_soft_or_raise + raise error +AssertionError: Locator expected to have count '1' +Actual value: 0 +Call log: + - Expect "to_have_count" with timeout 5000ms + - waiting for locator("iframe").content_frame.locator("iframe") + 14 × locator resolved to 0 elements + - unexpected value "0" diff --git a/notes/mcp-apps/experiments/4/results/mcp-server.log b/notes/mcp-apps/experiments/4/results/mcp-server.log new file mode 100644 index 000000000..23fbd45cf --- /dev/null +++ b/notes/mcp-apps/experiments/4/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: 8672bbdbb80c406fb22478c25f781e59 diff --git a/notes/mcp-apps/experiments/4/results/reference-host.json b/notes/mcp-apps/experiments/4/results/reference-host.json new file mode 100644 index 000000000..19765bd50 --- /dev/null +++ b/notes/mcp-apps/experiments/4/results/reference-host.json @@ -0,0 +1 @@ +null diff --git a/notes/mcp-apps/experiments/4/results/reference-host.log b/notes/mcp-apps/experiments/4/results/reference-host.log new file mode 100644 index 000000000..14d1daac2 --- /dev/null +++ b/notes/mcp-apps/experiments/4/results/reference-host.log @@ -0,0 +1,16 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop + +NotFoundError: Not Found + at error (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/send/index.js:168:31) + at pipe (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/send/index.js:468:14) + at sendfile (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/express/lib/response.js:1014:8) + at sendFile (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/express/lib/response.js:411:3) + at (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/examples/basic-host/serve.ts:127:7) + at handleRequest (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/layer.js:152:17) + at next (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/route.js:157:13) + at dispatch (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/route.js:117:3) + at handle (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/index.js:435:11) + at handleRequest (/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/ext-apps-10195ad9/node_modules/router/lib/layer.js:152:17) diff --git a/notes/mcp-apps/experiments/5/README.md b/notes/mcp-apps/experiments/5/README.md new file mode 100644 index 000000000..21073f647 --- /dev/null +++ b/notes/mcp-apps/experiments/5/README.md @@ -0,0 +1,33 @@ +# Experiment 5: Keep the reference host out of a dot-directory + +## Purpose + +Run the same compact ask with the pinned reference host cloned under `/tmp`, where Express can serve its sandbox HTML through the host's default `sendFile` policy. + +**Changes from experiment 4:** + +- Clone/cache the official reference host at `/tmp/leaf-mcp-apps-reference-10195ad9` instead of Leaf's hidden `.tmp/` directory. + +**Configuration:** + +- Transport: streamable HTTP adaptation of the shipped stdio MCP server +- UI: disposable `ui://` HTML resource +- Return path: `callServerTool` → Leaf `EventEndpoint` +- Supported ask: one current page-scoped, single-choice `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 a keyboard choice appends `choose` and teardown/reopen returns an empty ask, the app has no second state authority. +- If the 420×360 iframe has no horizontal overflow and exposes three short option labels, the compact projection is viable for the shipped substantial decision. +- If the host console is clean and the app never contacts a Leaf page server, the bundled resource and app-only tools are sufficient. + +## Findings + +The clone moved outside the hidden path, but npm 11 stopped before host launch +when `npm ci` was invoked with an absolute `--prefix`: it resolved workspace +links incorrectly and treated the clone directory name as a missing package. +The same install completed when run from inside the clone. Experiment 6 changes +only the install/build invocation's working directory. diff --git a/notes/mcp-apps/experiments/5/commands.sh b/notes/mcp-apps/experiments/5/commands.sh new file mode 100755 index 000000000..c7e8e7387 --- /dev/null +++ b/notes/mcp-apps/experiments/5/commands.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/5/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-5.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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"]' bun --watch 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/5/observe.py "$run_dir/page" \ + >"$results/reference-host.json" + +# Open the generated compact-app screenshot: +# open notes/mcp-apps/experiments/5/results/compact-ask-420x360.png diff --git a/notes/mcp-apps/experiments/5/observe.py b/notes/mcp-apps/experiments/5/observe.py new file mode 100755 index 000000000..7d308664c --- /dev/null +++ b/notes/mcp-apps/experiments/5/observe.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Run experiment 3's browser driver, writing into experiment 5.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "3/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_3_observe", source) +driver = module_from_spec(spec) +spec.loader.exec_module(driver) +driver.HERE = HERE +driver.driver.HERE = HERE +driver.main() diff --git a/notes/mcp-apps/experiments/5/output.txt b/notes/mcp-apps/experiments/5/output.txt new file mode 100644 index 000000000..50e48dfbd --- /dev/null +++ b/notes/mcp-apps/experiments/5/output.txt @@ -0,0 +1,31 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 7 items + +tests/test_mcp_app.py ....... [100%] + +============================== 7 passed in 2.41s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-5.5gNYde/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column +Cloning into '/tmp/leaf-mcp-apps-reference-10195ad9'... +Note: switching to '10195ad91851502134930e9b80ec2c04e277a720'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +HEAD is now at 10195ad9 docs: add Alpic Playground to supported clients (#729) diff --git a/notes/mcp-apps/experiments/6/README.md b/notes/mcp-apps/experiments/6/README.md new file mode 100644 index 000000000..43ff31e4b --- /dev/null +++ b/notes/mcp-apps/experiments/6/README.md @@ -0,0 +1,31 @@ +# Experiment 6: Install from the reference clone + +## Purpose + +Run the same compact ask after invoking npm from inside the pinned reference clone, which preserves its workspace link identities under npm 11. + +**Changes from experiment 5:** + +- Run `npm ci` and the basic-host build with the reference clone as the shell working directory instead of passing an absolute `--prefix`. + +**Configuration:** + +- Transport: streamable HTTP adaptation of the shipped stdio MCP server +- UI: disposable `ui://` HTML resource +- Return path: `callServerTool` → Leaf `EventEndpoint` +- Supported ask: one current page-scoped, single-choice `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 a keyboard choice appends `choose` and teardown/reopen returns an empty ask, the app has no second state authority. +- If the 420×360 iframe has no horizontal overflow and exposes three short option labels, the compact projection is viable for the shipped substantial decision. +- If the host console is clean and the app never contacts a Leaf page server, the bundled resource and app-only tools are sufficient. + +## Findings + +The vertical slice passed in the official reference host. The 420×360 outer frame gave the app a 414×354 content viewport; `scrollWidth` and `scrollHeight` equaled the client dimensions, and the three substantial authored options appeared as the short labels “Stateless JWT,” “Redis, cookie fallback,” and “Postgres table.” Keyboard activation appended an ordinary `choose` action on `session-options` selecting `opt-redis` at sequence 1. The app then showed “Nothing waiting here,” and showed the same after teardown plus a fresh model-visible open call, proving the log rather than iframe memory held the result. + +The host console recorded one unattributed 404. The recorder's `focused` field also captured `document.body.textContent` after the choice and reopen, which included the 385 KB inline bundle twice and made the JSON result needlessly large. Experiment 7 changes instrumentation only to identify the request and record focus by address rather than text content. diff --git a/notes/mcp-apps/experiments/6/commands.sh b/notes/mcp-apps/experiments/6/commands.sh new file mode 100755 index 000000000..a05e14646 --- /dev/null +++ b/notes/mcp-apps/experiments/6/commands.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/6/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-6.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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 +fi +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +if [ ! -d "$reference/node_modules" ]; then + (cd "$reference" && npm ci --silent) +fi +(cd "$reference" && npm 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"]' bun --watch 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/6/observe.py "$run_dir/page" \ + >"$results/reference-host.json" + +# Open the generated compact-app screenshot: +# open notes/mcp-apps/experiments/6/results/compact-ask-420x360.png diff --git a/notes/mcp-apps/experiments/6/observe.py b/notes/mcp-apps/experiments/6/observe.py new file mode 100755 index 000000000..274c828fb --- /dev/null +++ b/notes/mcp-apps/experiments/6/observe.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Run experiment 3's browser driver, writing into experiment 6.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +HERE = Path(__file__).parent +source = HERE.parent / "3/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_3_observe", source) +driver = module_from_spec(spec) +spec.loader.exec_module(driver) +driver.HERE = HERE +driver.driver.HERE = HERE +driver.main() diff --git a/notes/mcp-apps/experiments/6/output.txt b/notes/mcp-apps/experiments/6/output.txt new file mode 100644 index 000000000..cc8401f68 --- /dev/null +++ b/notes/mcp-apps/experiments/6/output.txt @@ -0,0 +1,12 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 7 items + +tests/test_mcp_app.py ....... [100%] + +============================== 7 passed in 2.00s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-6.9T6Uns/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column diff --git a/notes/mcp-apps/experiments/6/results/mcp-server.log b/notes/mcp-apps/experiments/6/results/mcp-server.log new file mode 100644 index 000000000..41b1fc394 --- /dev/null +++ b/notes/mcp-apps/experiments/6/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: f8810760030949b78c4de7e1dd6c2d64 diff --git a/notes/mcp-apps/experiments/6/results/reference-host.json.gz b/notes/mcp-apps/experiments/6/results/reference-host.json.gz new file mode 100644 index 000000000..c857e17a7 Binary files /dev/null and b/notes/mcp-apps/experiments/6/results/reference-host.json.gz differ diff --git a/notes/mcp-apps/experiments/6/results/reference-host.log b/notes/mcp-apps/experiments/6/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/6/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/7/README.md b/notes/mcp-apps/experiments/7/README.md new file mode 100644 index 000000000..ca70b56ae --- /dev/null +++ b/notes/mcp-apps/experiments/7/README.md @@ -0,0 +1,37 @@ +# Experiment 7: Attribute the host 404 + +## Purpose + +Repeat experiment 6's successful route with bounded focus evidence and failed-request URLs, so the remaining console error can be assigned to the app, host, or incidental browser chrome. + +**Changes from experiment 6:** + +- Record focused elements by tag/id/option address instead of full text content. +- Record URLs and status codes for HTTP failures, and URLs for failed requests. +- Record the official host's model-visible tool dropdown. + +**Configuration:** + +- Product, transport, fixture, reference-host commit, dimensions, and browser actions unchanged from experiment 6. + +**Expected outcomes:** + +- If the only 404 is host chrome such as `/favicon.ico`, the bundled app path is clean. +- If a failed request points at the `ui://` resource, MCP endpoint, or an undeclared external origin, the product slice is not yet complete. +- The result JSON should remain small after the choice and after reopen. + +## Findings + +The product result repeated cleanly and the bounded recorder reduced the result +from roughly 806 KB to 2.3 KB. The official host's model tool selector contained +only `leaf_open_page`; the two app-only tools remained unavailable to the model. +The app again fit a 414×354 content viewport without overflow, keyboard Enter +appended the expected `choose` event, and a teardown plus new open reconstructed +the settled state from the log. + +The only failed request was `http://localhost:3001/mcp` with +`net::ERR_ABORTED`, which is consistent with the host aborting its long-lived +MCP request while the app is closed or reopened. The one generic console 404 had +no matching failed response and still lacked a source URL, so this run narrowed +the network evidence but did not attribute that message. Experiment 8 adds +console locations and context-wide request diagnostics only. diff --git a/notes/mcp-apps/experiments/7/commands.sh b/notes/mcp-apps/experiments/7/commands.sh new file mode 100755 index 000000000..d9635e611 --- /dev/null +++ b/notes/mcp-apps/experiments/7/commands.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/7/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-7.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/7/observe.py "$run_dir/page" \ + >"$results/reference-host.json" + +# Open the generated compact-app screenshot: +# open notes/mcp-apps/experiments/7/results/compact-ask-420x360.png diff --git a/notes/mcp-apps/experiments/7/observe.py b/notes/mcp-apps/experiments/7/observe.py new file mode 100755 index 000000000..f310e5067 --- /dev/null +++ b/notes/mcp-apps/experiments/7/observe.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Repeat the successful reference-host run with bounded diagnostics.""" + +import json +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +from leaf.event_log import read_events +from playwright.sync_api import expect, sync_playwright + +HERE = Path(__file__).parent +source = HERE.parent / "1/observe.py" +spec = spec_from_file_location("mcp_apps_experiment_1_observe", source) +base = module_from_spec(spec) +spec.loader.exec_module(base) +base.HERE = HERE + + +def measure(frame): + return frame.locator("html").evaluate( + """() => { + const focused = document.activeElement; + return { + 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: focused ? { + tag: focused.localName, + id: focused.id || null, + option: focused.dataset?.option || null, + } : null, + }; + }""" + ) + + +def main() -> None: + page_dir = Path(sys.argv[1]).resolve() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel="chrome", headless=True) + page = browser.new_page(viewport={"width": 720, "height": 920}) + page.set_default_timeout(15_000) + console = [] + failed_responses = [] + failed_requests = [] + page.on( + "console", + lambda message: ( + console.append({"type": message.type, "text": message.text}) + if message.type in {"error", "warning"} + else None + ), + ) + page.on( + "response", + lambda response: ( + failed_responses.append( + {"status": response.status, "url": response.url} + ) + if response.status >= 400 + else None + ), + ) + page.on( + "requestfailed", + lambda request: failed_requests.append( + {"url": request.url, "failure": request.failure} + ), + ) + page.goto(base.HOST) + tool_select = page.locator("select").nth(1) + expect(tool_select).to_have_value("leaf_open_page") + model_tools = tool_select.locator("option").all_text_contents() + tool_input = page.locator("textarea") + expect(tool_input).to_have_value("{}") + tool_input.fill(json.dumps({"page": str(page_dir)})) + page.get_by_role("button", name="Call Tool").click() + outer, frame = base.app_frame(page) + expect( + frame.get_by_role("heading", name="Where should a session live?") + ).to_be_visible() + + 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 = base.app_frame(page) + expect( + frame.get_by_role("heading", name="Nothing waiting here") + ).to_be_visible() + reopened = measure(frame) + + print( + json.dumps( + { + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720", + }, + "model_visible_tools": model_tools, + "initial": initial, + "horizontal_overflow": initial["scroll"]["width"] + > initial["client"]["width"], + "vertical_overflow": initial["scroll"]["height"] + > initial["client"]["height"], + "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, + "failed_responses": failed_responses, + "failed_requests": failed_requests, + "screenshot": str(screenshot), + }, + indent=2, + ensure_ascii=False, + ) + ) + browser.close() + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/7/output.txt b/notes/mcp-apps/experiments/7/output.txt new file mode 100644 index 000000000..94036f4c1 --- /dev/null +++ b/notes/mcp-apps/experiments/7/output.txt @@ -0,0 +1,12 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 7 items + +tests/test_mcp_app.py ....... [100%] + +============================== 7 passed in 2.21s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-7.Ft8ap0/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column diff --git a/notes/mcp-apps/experiments/7/results/mcp-server.log b/notes/mcp-apps/experiments/7/results/mcp-server.log new file mode 100644 index 000000000..a5eb16b5f --- /dev/null +++ b/notes/mcp-apps/experiments/7/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: 3c7f3fb4bb084c649c7aacc04dc148a6 diff --git a/notes/mcp-apps/experiments/7/results/reference-host.json b/notes/mcp-apps/experiments/7/results/reference-host.json new file mode 100644 index 000000000..1a793cee9 --- /dev/null +++ b/notes/mcp-apps/experiments/7/results/reference-host.json @@ -0,0 +1,102 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "model_visible_tools": [ + "leaf_open_page" + ], + "initial": { + "viewport": { + "width": 414, + "height": 354 + }, + "client": { + "width": 414, + "height": 354 + }, + "scroll": { + "width": 414, + "height": 354 + }, + "question": "Where should a session live?", + "options": [ + "Stateless JWT", + "Redis, cookie fallback", + "Postgres table" + ], + "focused": { + "tag": "button", + "id": null, + "option": "opt-redis" + } + }, + "horizontal_overflow": false, + "vertical_overflow": false, + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 1 + }, + "after_choice": { + "viewport": { + "width": 414, + "height": 354 + }, + "client": { + "width": 414, + "height": 354 + }, + "scroll": { + "width": 414, + "height": 354 + }, + "question": "Nothing waiting here", + "options": [], + "focused": { + "tag": "body", + "id": null, + "option": null + } + }, + "after_teardown_and_reopen": { + "viewport": { + "width": 414, + "height": 354 + }, + "client": { + "width": 414, + "height": 354 + }, + "scroll": { + "width": 414, + "height": 354 + }, + "question": "Nothing waiting here", + "options": [], + "focused": { + "tag": "body", + "id": null, + "option": null + } + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)" + } + ], + "failed_responses": [], + "failed_requests": [ + { + "url": "http://localhost:3001/mcp", + "failure": "net::ERR_ABORTED" + } + ], + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/7/results/compact-ask-420x360.png" +} diff --git a/notes/mcp-apps/experiments/7/results/reference-host.log b/notes/mcp-apps/experiments/7/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/7/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/8/README.md b/notes/mcp-apps/experiments/8/README.md new file mode 100644 index 000000000..477d459e8 --- /dev/null +++ b/notes/mcp-apps/experiments/8/README.md @@ -0,0 +1,37 @@ +# Experiment 8: Locate the browser error + +## Purpose + +Repeat experiment 7 with console source locations and browser-context network +events, distinguishing an app resource failure from host teardown noise. + +**Changes from experiment 7:** + +- Record the URL and line supplied with each console message. +- Observe failed responses and requests on the browser context rather than only + the host page. +- Record the method and resource type for failed requests. + +**Configuration:** + +- Product, transport, fixture, reference-host commit, dimensions, and browser + actions unchanged from experiment 7. + +**Expected outcomes:** + +- A source URL or matching failed response should assign the generic 404 to the + app, host, sandbox, or incidental browser behavior. +- The app behavior and model-visible tool set should remain unchanged. + +## Findings + +The console location identified the generic 404 as +`http://localhost:8080/favicon.ico`, an incidental missing icon in the official +reference host. No response from the Leaf app resource, sandbox, or MCP service +failed. The remaining failed request was a `fetch` POST to the MCP endpoint with +`net::ERR_ABORTED` as the host tore down and reopened its long-lived session. + +The unchanged product path passed again: only `leaf_open_page` was model +visible, the compact ask fit without overflow, and keyboard activation appended +the expected ordinary Leaf action. There is no unresolved browser error in this +slice. diff --git a/notes/mcp-apps/experiments/8/commands.sh b/notes/mcp-apps/experiments/8/commands.sh new file mode 100755 index 000000000..55b32fd34 --- /dev/null +++ b/notes/mcp-apps/experiments/8/commands.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/8/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-8.XXXXXX")" +reference="/tmp/leaf-mcp-apps-reference-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 + +mkdir -p "$results" +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" + +test "$(git -C "$reference" rev-parse HEAD)" = "10195ad91851502134930e9b80ec2c04e277a720" +(cd "$reference" && npm 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"]' bun --watch 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/8/observe.py "$run_dir/page" \ + >"$results/reference-host.json" diff --git a/notes/mcp-apps/experiments/8/observe.py b/notes/mcp-apps/experiments/8/observe.py new file mode 100755 index 000000000..899567246 --- /dev/null +++ b/notes/mcp-apps/experiments/8/observe.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Locate the reference host's generic browser error.""" + +import json +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +from leaf.event_log import read_events +from playwright.sync_api import expect, sync_playwright + +HERE = Path(__file__).parent + + +def load(name: str, path: Path): + spec = spec_from_file_location(name, path) + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +base = load("mcp_apps_experiment_1_observe", HERE.parent / "1/observe.py") +previous = load("mcp_apps_experiment_7_observe", HERE.parent / "7/observe.py") +base.HERE = HERE + + +def main() -> None: + page_dir = Path(sys.argv[1]).resolve() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel="chrome", headless=True) + context = browser.new_context(viewport={"width": 720, "height": 920}) + page = context.new_page() + page.set_default_timeout(15_000) + console = [] + failed_responses = [] + failed_requests = [] + page.on( + "console", + lambda message: ( + console.append( + { + "type": message.type, + "text": message.text, + "location": message.location, + } + ) + if message.type in {"error", "warning"} + else None + ), + ) + context.on( + "response", + lambda response: ( + failed_responses.append( + { + "status": response.status, + "url": response.url, + "method": response.request.method, + "resource_type": response.request.resource_type, + } + ) + if response.status >= 400 + else None + ), + ) + context.on( + "requestfailed", + lambda request: failed_requests.append( + { + "url": request.url, + "method": request.method, + "resource_type": request.resource_type, + "failure": request.failure, + } + ), + ) + + page.goto(base.HOST) + tool_select = page.locator("select").nth(1) + expect(tool_select).to_have_value("leaf_open_page") + model_tools = tool_select.locator("option").all_text_contents() + tool_input = page.locator("textarea") + expect(tool_input).to_have_value("{}") + tool_input.fill(json.dumps({"page": str(page_dir)})) + page.get_by_role("button", name="Call Tool").click() + outer, frame = base.app_frame(page) + expect( + frame.get_by_role("heading", name="Where should a session live?") + ).to_be_visible() + + option = frame.get_by_role("button", name="Redis, cookie fallback") + option.focus() + initial = previous.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() + 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 = base.app_frame(page) + expect( + frame.get_by_role("heading", name="Nothing waiting here") + ).to_be_visible() + + print( + json.dumps( + { + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720", + }, + "model_visible_tools": model_tools, + "initial": initial, + "keyboard_choice": { + "widget": event["widget"], + "action": event["action"], + "detail": event["detail"], + "seq": event["seq"], + }, + "console": console, + "failed_responses": failed_responses, + "failed_requests": failed_requests, + "screenshot": str(screenshot), + }, + indent=2, + ensure_ascii=False, + ) + ) + browser.close() + + +if __name__ == "__main__": + main() diff --git a/notes/mcp-apps/experiments/8/output.txt b/notes/mcp-apps/experiments/8/output.txt new file mode 100644 index 000000000..254440e86 --- /dev/null +++ b/notes/mcp-apps/experiments/8/output.txt @@ -0,0 +1,12 @@ +============================= test session starts ============================== +platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 +rootdir: /Users/maximilian/.codex/worktrees/a5c8/leaf +configfile: pyproject.toml +plugins: xdist-3.8.0, anyio-4.14.2 +collected 7 items + +tests/test_mcp_app.py ....... [100%] + +============================== 7 passed in 1.95s =============================== +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-8.MPlmfn/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column diff --git a/notes/mcp-apps/experiments/8/results/mcp-server.log b/notes/mcp-apps/experiments/8/results/mcp-server.log new file mode 100644 index 000000000..15820a9e8 --- /dev/null +++ b/notes/mcp-apps/experiments/8/results/mcp-server.log @@ -0,0 +1,2 @@ +StreamableHTTP session manager started +Created new transport with session ID: 0a99c50b79324d2d820dd3d3ab1846ed diff --git a/notes/mcp-apps/experiments/8/results/reference-host.json b/notes/mcp-apps/experiments/8/results/reference-host.json new file mode 100644 index 000000000..04754cbdb --- /dev/null +++ b/notes/mcp-apps/experiments/8/results/reference-host.json @@ -0,0 +1,67 @@ +{ + "reference_host": { + "repo": "https://github.com/modelcontextprotocol/ext-apps", + "commit": "10195ad91851502134930e9b80ec2c04e277a720" + }, + "model_visible_tools": [ + "leaf_open_page" + ], + "initial": { + "viewport": { + "width": 414, + "height": 354 + }, + "client": { + "width": 414, + "height": 354 + }, + "scroll": { + "width": 414, + "height": 354 + }, + "question": "Where should a session live?", + "options": [ + "Stateless JWT", + "Redis, cookie fallback", + "Postgres table" + ], + "focused": { + "tag": "button", + "id": null, + "option": "opt-redis" + } + }, + "keyboard_choice": { + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "seq": 1 + }, + "console": [ + { + "type": "error", + "text": "Failed to load resource: the server responded with a status of 404 (Not Found)", + "location": { + "url": "http://localhost:8080/favicon.ico", + "line": 0, + "column": 0, + "lineNumber": 0, + "columnNumber": 0 + } + } + ], + "failed_responses": [], + "failed_requests": [ + { + "url": "http://localhost:3001/mcp", + "method": "POST", + "resource_type": "fetch", + "failure": "net::ERR_ABORTED" + } + ], + "screenshot": "/Users/maximilian/.codex/worktrees/a5c8/leaf/notes/mcp-apps/experiments/8/results/compact-ask-420x360.png" +} diff --git a/notes/mcp-apps/experiments/8/results/reference-host.log b/notes/mcp-apps/experiments/8/results/reference-host.log new file mode 100644 index 000000000..045bfdd35 --- /dev/null +++ b/notes/mcp-apps/experiments/8/results/reference-host.log @@ -0,0 +1,4 @@ +Host server: http://localhost:8080 +Sandbox server: http://localhost:8081 + +Press Ctrl+C to stop diff --git a/notes/mcp-apps/experiments/9/README.md b/notes/mcp-apps/experiments/9/README.md new file mode 100644 index 000000000..7c58ac5c9 --- /dev/null +++ b/notes/mcp-apps/experiments/9/README.md @@ -0,0 +1,44 @@ +# Experiment 9: Shipped stdio boundary + +## Purpose + +Exercise the user-facing `leaf mcp run` command as a real subprocess with the +official Python MCP client, rather than calling the server object in-process or +adapting it to HTTP for the reference host. + +**Changes from experiment 8:** + +- Transport is the shipped stdio command, with no HTTP adapter or browser host. +- Negotiate the MCP protocol and inspect advertised tools and resources. +- Read the actual bundled `ui://` HTML through MCP. +- Call open and post, end the process, then read from a second fresh process. + +**Configuration:** + +- Server command: `bin/leaf mcp run` +- Client: the official Python MCP SDK resolved by this repository +- Authored fixture: `examples/design-decision.html` + +**Expected outcomes:** + +- Initialization, discovery, resource read, and tool calls complete without + protocol or stderr contamination. +- The HTML resource is the committed self-contained app and carries the MCP App + MIME profile. +- A second process reconstructs the settled state from `comments.jsonl`. + +## Findings + +The shipped command completed the full MCP exchange in two independent stdio +processes. Both negotiated protocol `2025-11-25`; discovery returned one +model-visible open tool, two app-only read/write tools, and the single +`ui://leaf/compact-ask.html` resource with +`text/html;profile=mcp-app`. Reading that resource produced the exact SHA-256 of +the committed bundle (`f8dbab209fb870925c48016c196181ac4cc5cf047d58c070a232aadf10a29339`). + +The first process opened the design-decision ask and posted `opt-redis` through +`leaf_post_event`, receiving status 200 and an empty projection at event 1. It +then exited. The second process independently opened the page at event 1 and +returned the same empty projection. `comments.jsonl` contained the ordinary +validated `choose` action. No protocol diagnostics contaminated stdout or +appeared on stderr. diff --git a/notes/mcp-apps/experiments/9/commands.sh b/notes/mcp-apps/experiments/9/commands.sh new file mode 100755 index 000000000..aebc395c7 --- /dev/null +++ b/notes/mcp-apps/experiments/9/commands.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e +repo="$(git rev-parse --show-toplevel)" +cd "$repo" + +results="$repo/notes/mcp-apps/experiments/9/results" +run_dir="$(mktemp -d "$repo/.tmp/mcp-app-experiment-9.XXXXXX")" +mkdir -p "$results" + +./bin/leaf page init "$run_dir/page" +cp examples/design-decision.html "$run_dir/page/index.html" +./bin/leaf version check "$run_dir/page" + +uv run python notes/mcp-apps/experiments/9/observe.py "$run_dir/page" \ + >"$results/stdio.json" diff --git a/notes/mcp-apps/experiments/9/observe.py b/notes/mcp-apps/experiments/9/observe.py new file mode 100755 index 000000000..e1958c5bc --- /dev/null +++ b/notes/mcp-apps/experiments/9/observe.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Probe the shipped MCP server across its real stdio process boundary.""" + +import hashlib +import json +import sys +from pathlib import Path + +import anyio +from leaf.event_log import read_events +from leaf.mcp_app import APP_RESOURCE, RESOURCE_URI +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +REPO = Path(__file__).resolve().parents[4] + + +async def session(page_dir: Path, *, settle: bool) -> dict: + params = StdioServerParameters( + command=str(REPO / "bin/leaf"), + args=["mcp", "run"], + cwd=REPO, + ) + async with ( + stdio_client(params) as (read_stream, write_stream), + ClientSession(read_stream, write_stream) as client, + ): + initialized = await client.initialize() + tools = await client.list_tools() + resources = await client.list_resources() + resource = await client.read_resource(RESOURCE_URI) + opened = await client.call_tool("leaf_open_page", {"page": str(page_dir)}) + state = opened.structured_content["state"] + result = { + "initialize": initialized.model_dump(by_alias=True), + "tools": [tool.model_dump(by_alias=True) for tool in tools.tools], + "resources": [ + item.model_dump(by_alias=True) for item in resources.resources + ], + "resource": { + "uri": str(resource.contents[0].uri), + "mime_type": resource.contents[0].mime_type, + "length": len(resource.contents[0].text), + "sha256": hashlib.sha256( + resource.contents[0].text.encode("utf-8") + ).hexdigest(), + }, + "opened": state, + } + if settle: + submit = state["ask"]["submit"] + posted = await client.call_tool( + "leaf_post_event", + { + "page": str(page_dir), + "event": { + **submit, + "detail": {"options": ["opt-redis"]}, + "attempt": "mcp-app-stdio-0001", + }, + }, + ) + result["posted"] = posted.structured_content + return result + + +async def main(page_dir: Path) -> None: + first = await session(page_dir, settle=True) + second = await session(page_dir, settle=False) + shipped = APP_RESOURCE.read_bytes() + print( + json.dumps( + { + "first_process": first, + "second_process": second, + "shipped_resource": { + "length": len(shipped), + "sha256": hashlib.sha256(shipped).hexdigest(), + }, + "log": read_events(page_dir), + }, + indent=2, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + anyio.run(main, Path(sys.argv[1]).resolve()) diff --git a/notes/mcp-apps/experiments/9/output.txt b/notes/mcp-apps/experiments/9/output.txt new file mode 100644 index 000000000..2d808b70c --- /dev/null +++ b/notes/mcp-apps/experiments/9/output.txt @@ -0,0 +1,2 @@ +initialized /Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-9.WWYySG/page +✓ index.html: parses, widgets validate, one module script + theme link, protected ids and decisions carried over, nothing overflows the 720px column diff --git a/notes/mcp-apps/experiments/9/results/stdio.json b/notes/mcp-apps/experiments/9/results/stdio.json new file mode 100644 index 000000000..5c61cf30e --- /dev/null +++ b/notes/mcp-apps/experiments/9/results/stdio.json @@ -0,0 +1,458 @@ +{ + "first_process": { + "initialize": { + "_meta": null, + "protocolVersion": "2025-11-25", + "capabilities": { + "experimental": {}, + "logging": null, + "prompts": { + "listChanged": false + }, + "resources": { + "subscribe": false, + "listChanged": false + }, + "tools": { + "listChanged": false + }, + "completions": null, + "extensions": null, + "tasks": null + }, + "serverInfo": { + "name": "Leaf", + "title": null, + "version": "0.1.0", + "description": "Open compact Leaf asks backed by the ordinary page log.", + "websiteUrl": null, + "icons": null + }, + "instructions": null + }, + "tools": [ + { + "name": "leaf_open_page", + "title": "Open a compact Leaf ask", + "description": "Open one initialized Leaf page as a compact inline ask. Use when the user wants to review or answer that page inside the conversation.", + "inputSchema": { + "properties": { + "page": { + "title": "Page", + "type": "string" + } + }, + "required": [ + "page" + ], + "type": "object", + "title": "leaf_open_pageArguments" + }, + "execution": null, + "outputSchema": null, + "icons": null, + "annotations": { + "title": null, + "readOnlyHint": true, + "destructiveHint": null, + "idempotentHint": null, + "openWorldHint": false + }, + "_meta": { + "ui": { + "resourceUri": "ui://leaf/compact-ask.html", + "visibility": [ + "model" + ] + } + } + }, + { + "name": "leaf_read_page", + "title": null, + "description": "Read the current compact projection of an initialized Leaf page.", + "inputSchema": { + "properties": { + "page": { + "title": "Page", + "type": "string" + } + }, + "required": [ + "page" + ], + "type": "object", + "title": "leaf_read_pageArguments" + }, + "execution": null, + "outputSchema": null, + "icons": null, + "annotations": { + "title": null, + "readOnlyHint": true, + "destructiveHint": null, + "idempotentHint": null, + "openWorldHint": false + }, + "_meta": { + "ui": { + "resourceUri": "ui://leaf/compact-ask.html", + "visibility": [ + "app" + ] + } + } + }, + { + "name": "leaf_post_event", + "title": null, + "description": "Validate and append one browser-shaped event to an initialized Leaf page.", + "inputSchema": { + "properties": { + "page": { + "title": "Page", + "type": "string" + }, + "event": { + "additionalProperties": true, + "title": "Event", + "type": "object" + } + }, + "required": [ + "page", + "event" + ], + "type": "object", + "title": "leaf_post_eventArguments" + }, + "execution": null, + "outputSchema": null, + "icons": null, + "annotations": { + "title": null, + "readOnlyHint": null, + "destructiveHint": false, + "idempotentHint": null, + "openWorldHint": false + }, + "_meta": { + "ui": { + "resourceUri": "ui://leaf/compact-ask.html", + "visibility": [ + "app" + ] + } + } + } + ], + "resources": [ + { + "name": "ui://leaf/compact-ask.html", + "title": "Leaf compact ask", + "uri": "ui://leaf/compact-ask.html", + "description": "A disposable inline projection of one current Leaf options ask.", + "mimeType": "text/html;profile=mcp-app", + "size": null, + "icons": null, + "annotations": null, + "_meta": { + "ui": { + "prefersBorder": true + } + } + } + ], + "resource": { + "uri": "ui://leaf/compact-ask.html", + "mime_type": "text/html;profile=mcp-app", + "length": 385152, + "sha256": "f8dbab209fb870925c48016c196181ac4cc5cf047d58c070a232aadf10a29339" + }, + "opened": { + "format": "leaf.compact-ask/v1", + "page": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-9.WWYySG/page", + "title": "Decision: where sessions live", + "event_seq": 0, + "active": { + "revision": 1, + "version": null, + "url": "/revisions/r1-31077e1fbb435da3.html", + "label": "Draft", + "activated_at": "2026-08-31T03:26:59.067451+00:00", + "file": "revisions/r1-31077e1fbb435da3.html" + }, + "source_error": null, + "full_page_url": null, + "mode": "ask", + "reason": null, + "message": "Choose one option.", + "ask": { + "surface": "session-options-decision", + "source": "session-options", + "question": "Where should a session live?", + "context": "", + "options": [ + { + "id": "opt-jwt", + "label": "Stateless JWT", + "summary": "effort: low risk: high Stateless JWT Revoke build a denylist Outage unaffected New ops none, at first Sessions become signed tokens; no store at all. Revocation requires a denylist, which quietly reintroduces the store — with the hard parts (replication, expiry) still attached, and the account-takeover response waiting on them. With nothing stored there is nothing to list, so the support console loses its per-device Revoke and \"log out all devices\" waits on the token's own expiry." + }, + { + "id": "opt-redis", + "label": "Redis, cookie fallback", + "summary": "effort: med risk: low Redis, cookie fallback Revoke delete the key Outage reads ride the cookie New ops what we already do Sessions in the Redis we already run, keyed by an opaque id; a short-lived signed cookie covers Redis outages for reads, so a blip doesn't log everyone out. Revocation is a delete — which is the shape support's \"log out all devices\" needs. My take: this preserves revocation without making Redis an outage." + }, + { + "id": "opt-db", + "label": "Postgres table", + "summary": "effort: low risk: med Postgres table Revoke delete the row Outage shares the database's fate New ops vacuum pressure One table, no new moving parts. Every request writes a row (rolling expiry), so at our volume that's the primary's headroom spent on expiry bookkeeping — and a session outage becomes a database outage." + } + ], + "submit": { + "kind": "action", + "revision": 1, + "widget": "session-options", + "action": "choose" + } + } + }, + "posted": { + "ok": true, + "state": { + "format": "leaf.compact-ask/v1", + "page": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-9.WWYySG/page", + "title": "Decision: where sessions live", + "event_seq": 1, + "active": { + "revision": 1, + "version": null, + "url": "/revisions/r1-31077e1fbb435da3.html", + "label": "Draft", + "activated_at": "2026-08-31T03:26:59.067451+00:00", + "file": "revisions/r1-31077e1fbb435da3.html" + }, + "source_error": null, + "full_page_url": null, + "mode": "empty", + "reason": "no-open-ask", + "message": "This page has no open ask.", + "ask": null + }, + "status": 200 + } + }, + "second_process": { + "initialize": { + "_meta": null, + "protocolVersion": "2025-11-25", + "capabilities": { + "experimental": {}, + "logging": null, + "prompts": { + "listChanged": false + }, + "resources": { + "subscribe": false, + "listChanged": false + }, + "tools": { + "listChanged": false + }, + "completions": null, + "extensions": null, + "tasks": null + }, + "serverInfo": { + "name": "Leaf", + "title": null, + "version": "0.1.0", + "description": "Open compact Leaf asks backed by the ordinary page log.", + "websiteUrl": null, + "icons": null + }, + "instructions": null + }, + "tools": [ + { + "name": "leaf_open_page", + "title": "Open a compact Leaf ask", + "description": "Open one initialized Leaf page as a compact inline ask. Use when the user wants to review or answer that page inside the conversation.", + "inputSchema": { + "properties": { + "page": { + "title": "Page", + "type": "string" + } + }, + "required": [ + "page" + ], + "type": "object", + "title": "leaf_open_pageArguments" + }, + "execution": null, + "outputSchema": null, + "icons": null, + "annotations": { + "title": null, + "readOnlyHint": true, + "destructiveHint": null, + "idempotentHint": null, + "openWorldHint": false + }, + "_meta": { + "ui": { + "resourceUri": "ui://leaf/compact-ask.html", + "visibility": [ + "model" + ] + } + } + }, + { + "name": "leaf_read_page", + "title": null, + "description": "Read the current compact projection of an initialized Leaf page.", + "inputSchema": { + "properties": { + "page": { + "title": "Page", + "type": "string" + } + }, + "required": [ + "page" + ], + "type": "object", + "title": "leaf_read_pageArguments" + }, + "execution": null, + "outputSchema": null, + "icons": null, + "annotations": { + "title": null, + "readOnlyHint": true, + "destructiveHint": null, + "idempotentHint": null, + "openWorldHint": false + }, + "_meta": { + "ui": { + "resourceUri": "ui://leaf/compact-ask.html", + "visibility": [ + "app" + ] + } + } + }, + { + "name": "leaf_post_event", + "title": null, + "description": "Validate and append one browser-shaped event to an initialized Leaf page.", + "inputSchema": { + "properties": { + "page": { + "title": "Page", + "type": "string" + }, + "event": { + "additionalProperties": true, + "title": "Event", + "type": "object" + } + }, + "required": [ + "page", + "event" + ], + "type": "object", + "title": "leaf_post_eventArguments" + }, + "execution": null, + "outputSchema": null, + "icons": null, + "annotations": { + "title": null, + "readOnlyHint": null, + "destructiveHint": false, + "idempotentHint": null, + "openWorldHint": false + }, + "_meta": { + "ui": { + "resourceUri": "ui://leaf/compact-ask.html", + "visibility": [ + "app" + ] + } + } + } + ], + "resources": [ + { + "name": "ui://leaf/compact-ask.html", + "title": "Leaf compact ask", + "uri": "ui://leaf/compact-ask.html", + "description": "A disposable inline projection of one current Leaf options ask.", + "mimeType": "text/html;profile=mcp-app", + "size": null, + "icons": null, + "annotations": null, + "_meta": { + "ui": { + "prefersBorder": true + } + } + } + ], + "resource": { + "uri": "ui://leaf/compact-ask.html", + "mime_type": "text/html;profile=mcp-app", + "length": 385152, + "sha256": "f8dbab209fb870925c48016c196181ac4cc5cf047d58c070a232aadf10a29339" + }, + "opened": { + "format": "leaf.compact-ask/v1", + "page": "/Users/maximilian/.codex/worktrees/a5c8/leaf/.tmp/mcp-app-experiment-9.WWYySG/page", + "title": "Decision: where sessions live", + "event_seq": 1, + "active": { + "revision": 1, + "version": null, + "url": "/revisions/r1-31077e1fbb435da3.html", + "label": "Draft", + "activated_at": "2026-08-31T03:26:59.067451+00:00", + "file": "revisions/r1-31077e1fbb435da3.html" + }, + "source_error": null, + "full_page_url": null, + "mode": "empty", + "reason": "no-open-ask", + "message": "This page has no open ask.", + "ask": null + } + }, + "shipped_resource": { + "length": 385160, + "sha256": "f8dbab209fb870925c48016c196181ac4cc5cf047d58c070a232aadf10a29339" + }, + "log": [ + { + "kind": "action", + "revision": 1, + "widget": "session-options", + "action": "choose", + "detail": { + "options": [ + "opt-redis" + ] + }, + "attempt": "mcp-app-stdio-0001", + "author": "user", + "id": "aa5e0fef0fb7c36dc39a746d7f63f4a0", + "ts": "2026-08-30T20:26:59-07:00", + "seq": 1 + } + ] +} diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index a67d203cb..b7a859b48 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -48,7 +48,11 @@ published file is already loadable: - Builds, where it is not, or where what ships is cut down to what the registry declares: `highlight` and `pierre`, both cut to `$languages.names`, and `plot`, bundled with d3 because Plot's published ESM leaves d3 as a bare - external import. + external import. `mcp-app` is the separate delivery-surface build: each tracked + shell under `scripts/mcp-app/` becomes one HTML blob containing the MCP Apps + SDK, its application code and styles, and the Leaf mark. The page shell then + frames the exact process-scoped origin declared by the resource. The separate + comments-only fallback lives as a self-contained authored asset. A bundle reproduces its tracked bytes exactly when every input it fetches is pinned, which holds for the three copies and for `highlight`, so a clean diff --git a/scripts/mcp-app/page-app.css b/scripts/mcp-app/page-app.css new file mode 100644 index 000000000..94330553c --- /dev/null +++ b/scripts/mcp-app/page-app.css @@ -0,0 +1,192 @@ +:root { + color-scheme: light dark; + --leaf-bg: var(--color-background-primary, light-dark(#fbfaf7, #171a1d)); + --leaf-panel: var(--color-background-secondary, light-dark(#f2f0eb, #20252a)); + --leaf-ink: var(--color-text-primary, light-dark(#202522, #eef2ee)); + --leaf-muted: var(--color-text-secondary, light-dark(#626965, #abb5ae)); + --leaf-border: var(--color-border-secondary, light-dark(#d8d4cb, #3d454b)); + --leaf-ring: var(--color-ring-primary, light-dark(#365f90, #9bbce4)); + --leaf-warning: var(--color-text-warning, light-dark(#875b16, #e4bd75)); +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--leaf-bg); +} + +body, +button { + font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system, sans-serif); +} + +body { + color: var(--leaf-ink); +} + +.shell { + width: 100%; + height: 100vh; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + padding: + var(--safe-top, 0px) + var(--safe-right, 0px) + var(--safe-bottom, 0px) + var(--safe-left, 0px); +} + +.bar, +.foot { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + background: var(--leaf-panel); +} + +.bar { + min-height: 38px; + justify-content: space-between; + padding: 5px 8px 5px 10px; + border-bottom: 1px solid var(--leaf-border); +} + +.identity, +.actions { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.identity { + overflow: hidden; +} + +.mark, +.mark svg { + width: 19px; + height: 19px; + display: block; + flex: 0 0 19px; +} + +.product { + font-size: 12px; + font-weight: var(--font-weight-semibold, 650); +} + +.page-title { + min-width: 0; + overflow: hidden; + color: var(--leaf-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +button { + min-height: 27px; + padding: 3px 7px; + border: 1px solid var(--leaf-border); + border-radius: var(--border-radius-sm, 5px); + background: var(--leaf-bg); + color: var(--leaf-ink); + font-size: 11px; + line-height: 1; + white-space: nowrap; + cursor: pointer; +} + +button:hover:not(:disabled) { + border-color: var(--leaf-muted); +} + +button:focus-visible { + outline: 2px solid var(--leaf-ring); + outline-offset: 2px; +} + +button:disabled { + cursor: wait; + opacity: 0.55; +} + +.surface { + min-width: 0; + min-height: 0; + position: relative; + background: var(--leaf-bg); +} + +#leaf-page { + width: 100%; + height: 100%; + display: block; + border: 0; + background: var(--leaf-bg); +} + +#leaf-page[hidden] { + display: none; +} + +.loading { + max-width: 34rem; + margin: 24px; + color: var(--leaf-muted); + font-size: 13px; + line-height: 1.45; +} + +.foot { + min-height: 24px; + padding: 3px 9px; + border-top: 1px solid var(--leaf-border); + color: var(--leaf-muted); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 9px; +} + +#source-warning { + color: var(--leaf-warning); +} + +#status { + min-width: 0; + margin-left: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 540px) { + .bar { + align-items: stretch; + flex-direction: column; + } + + .actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .actions button { + overflow: hidden; + text-overflow: ellipsis; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + scroll-behavior: auto !important; + } +} diff --git a/scripts/mcp-app/page-app.html b/scripts/mcp-app/page-app.html new file mode 100644 index 000000000..f4b52cbe4 --- /dev/null +++ b/scripts/mcp-app/page-app.html @@ -0,0 +1,45 @@ + + + + + + + Leaf page + + + +
+
+
+ + Leaf + Opening page… +
+
+ + + +
+
+
+

Starting the Leaf page…

+ +
+
+ Waiting for Leaf + + +
+
+ + + diff --git a/scripts/mcp-app/page-app.js b/scripts/mcp-app/page-app.js new file mode 100644 index 000000000..2494f8af4 --- /dev/null +++ b/scripts/mcp-app/page-app.js @@ -0,0 +1,157 @@ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, +} from "@modelcontextprotocol/ext-apps/app-with-deps"; + +const app = new App( + { name: "Leaf page", version: "0.1.0" }, + { availableDisplayModes: ["inline", "fullscreen"] }, +); +const shell = document.querySelector("#app"); +const frame = document.querySelector("#leaf-page"); +const loading = document.querySelector("#loading"); +const pageTitle = document.querySelector("#page-title"); +const sequence = document.querySelector("#sequence"); +const sourceWarning = document.querySelector("#source-warning"); +const status = document.querySelector("#status"); +const refresh = document.querySelector("#refresh"); +const openPage = document.querySelector("#open-page"); +const displayMode = document.querySelector("#display-mode"); +let current = null; +let pagePath = null; +let mode = "inline"; +let busy = false; + +function payload(result) { + return ( + result?._meta?.leaf ?? + result?.structuredContent ?? + result?.structured_content ?? + null + ); +} + +function announce(message) { + status.textContent = ""; + requestAnimationFrame(() => { + status.textContent = message; + }); +} + +function setBusy(value) { + busy = value; + refresh.disabled = value; + displayMode.disabled = value; +} + +function safePageUrl(value) { + const url = new URL(value); + if (url.protocol !== "http:" || url.hostname !== "localhost") + throw new Error("Leaf returned an unexpected page address"); + return url.href; +} + +function render(state, announcement) { + current = state; + pagePath = state.page ?? pagePath; + pageTitle.textContent = state.title || "Untitled page"; + sequence.textContent = `event ${state.event_seq} · ${state.active?.label ?? "no revision"}`; + sourceWarning.hidden = !state.source_error; + const inlineUrl = state.inline_url; + const browserUrl = state.browser_url ?? inlineUrl; + openPage.disabled = !browserUrl; + if (state.mode !== "page" || !inlineUrl) { + frame.hidden = true; + frame.removeAttribute("src"); + loading.hidden = false; + loading.textContent = state.message; + } else { + loading.hidden = true; + frame.hidden = false; + const next = safePageUrl(inlineUrl); + if (frame.src !== next) frame.src = next; + } + if (announcement) announce(announcement); + else status.textContent = ""; +} + +async function readCurrent() { + if (busy || !pagePath) return; + setBusy(true); + try { + const result = await app.callServerTool({ + name: "leaf_refresh", + arguments: { page: pagePath }, + }); + const answer = payload(result); + if (!answer?.page) throw new Error(answer?.error ?? "Leaf refused the read"); + render(answer, "Refreshed the Leaf page"); + } catch (error) { + announce(`Could not refresh: ${String(error)}`); + } finally { + setBusy(false); + } +} + +function applyContext(context) { + if (!context) return; + if (context.theme) applyDocumentTheme(context.theme); + if (context.styles?.variables) applyHostStyleVariables(context.styles.variables); + if (context.styles?.css?.fonts) applyHostFonts(context.styles.css.fonts); + if (context.safeAreaInsets) { + for (const edge of ["top", "right", "bottom", "left"]) + shell.style.setProperty(`--safe-${edge}`, `${context.safeAreaInsets[edge]}px`); + } + mode = context.displayMode ?? mode; + if (context.availableDisplayModes !== undefined) + displayMode.hidden = !context.availableDisplayModes.includes("fullscreen"); + displayMode.textContent = mode === "fullscreen" ? "Return inline" : "Full screen"; +} + +app.ontoolresult = (result) => { + const answer = payload(result); + if (answer?.page) render(answer); +}; +app.onhostcontextchanged = applyContext; +app.onerror = (error) => announce(`Host error: ${String(error)}`); +app.onteardown = async () => { + frame.src = "about:blank"; + return {}; +}; + +frame.addEventListener("load", () => announce("Leaf page loaded")); +refresh.addEventListener("click", readCurrent); +openPage.addEventListener("click", async () => { + const url = current?.browser_url ?? current?.inline_url; + if (!url) return; + try { + const answer = await app.openLink({ url }); + if (answer?.isError) announce("The host did not open the page"); + } catch (error) { + announce(`Could not open the page: ${String(error)}`); + } +}); +displayMode.addEventListener("click", async () => { + if (busy) return; + setBusy(true); + try { + const next = mode === "fullscreen" ? "inline" : "fullscreen"; + const answer = await app.requestDisplayMode({ mode: next }); + mode = answer.mode; + displayMode.textContent = mode === "fullscreen" ? "Return inline" : "Full screen"; + } catch (error) { + announce(`Could not change the display: ${String(error)}`); + } finally { + setBusy(false); + } +}); + +app + .connect() + .then(() => applyContext(app.getHostContext())) + .catch((error) => { + loading.textContent = `Could not connect to this host: ${String(error)}`; + announce("Leaf could not connect to this host"); + }); diff --git a/scripts/vendor.py b/scripts/vendor.py index 5fe71f9ca..c02e7d107 100755 --- a/scripts/vendor.py +++ b/scripts/vendor.py @@ -41,6 +41,7 @@ "sortablejs": "1.15.7", "@observablehq/plot": "0.6.17", "@pierre/diffs": "1.3.6", + "@modelcontextprotocol/ext-apps": "1.7.5", "shiki": "4.4.3", "esbuild": "0.28.2", } @@ -427,8 +428,68 @@ def build_pierre(work: Path) -> list[Path]: return [out, notices] +def build_mcp_app(work: Path) -> list[Path]: + """Bundle the MCP Apps surfaces into self-contained `ui://` resources. + + An MCP host reads one HTML blob from the server; it does not fetch Leaf's + ordinary app assets. The SDK, application code, styles, and existing Leaf + mark are therefore inlined into committed files that an installed plugin can + serve without npm or network access. The complete-page resource may frame + the process-scoped page server, but the resource itself remains standalone. + """ + source = ROOT / "scripts/mcp-app" + run( + "npm", + "install", + "--no-save", + "--no-package-lock", + "--silent", + spec("@modelcontextprotocol/ext-apps"), + spec("esbuild"), + cwd=work, + ) + outputs = [] + for name in ("page",): + entry = work / f"{name}-entry.js" + bundle = work / f"{name}-bundle.js" + out = ASSETS / f"vendor/mcp-{name}-app.html" + shutil.copyfile(source / f"{name}-app.js", entry) + esbuild( + entry.name, + "--bundle", + "--format=iife", + "--platform=browser", + "--target=chrome105", + "--minify", + "--legal-comments=inline", + f"--banner:js=/*! @modelcontextprotocol/ext-apps {PINS['@modelcontextprotocol/ext-apps']}" + " — MIT — https://github.com/modelcontextprotocol/ext-apps */", + f"--outfile={bundle}", + cwd=work, + ) + html = (source / f"{name}-app.html").read_text(encoding="utf-8") + html = html.replace( + "/* LEAF_MCP_STYLE */", + (source / f"{name}-app.css").read_text(encoding="utf-8").strip(), + ) + html = html.replace( + "/* LEAF_MCP_SCRIPT */", + bundle.read_text(encoding="utf-8") + .strip() + .replace("", + (ASSETS / "icon.svg").read_text(encoding="utf-8").strip(), + ) + out.write_text(html, encoding="utf-8") + outputs.append(out) + return outputs + + BUILDS: dict[str, Callable[[Path], list[Path]]] = { "highlight": build_highlight, + "mcp-app": build_mcp_app, "plot": build_plot, "pierre": build_pierre, } @@ -451,8 +512,9 @@ def vendor(name: str) -> list[Path]: "highlight.js": ("highlight",), "@observablehq/plot": ("plot",), "@pierre/diffs": ("pierre",), + "@modelcontextprotocol/ext-apps": ("mcp-app",), "shiki": ("pierre",), - "esbuild": ("highlight", "plot", "pierre"), + "esbuild": ("highlight", "mcp-app", "plot", "pierre"), } diff --git a/skills/leaf/SKILL.md b/skills/leaf/SKILL.md index 32753d05b..1463cbac5 100644 --- a/skills/leaf/SKILL.md +++ b/skills/leaf/SKILL.md @@ -148,7 +148,7 @@ so a phase does not depend on discovering a chain of references. validation, passages, or parsed source. These internal contracts are not for ordinary page use. - `references/internals/mcp-app.md`: when changing the MCP tools, app resource, - host bridge, private result payload, or MCP delivery acknowledgement. + process-scoped page server, private result payload, or snapshot fallback. ### Use a separate Codex watcher diff --git a/skills/leaf/assets/leaf.js b/skills/leaf/assets/leaf.js index df5b499d9..2bba2f47c 100644 --- a/skills/leaf/assets/leaf.js +++ b/skills/leaf/assets/leaf.js @@ -687,7 +687,7 @@ const commentsEdge = drawnEdge({ covering: COVERING, }); -const banner = el("div", "lf-ui lf-banner"); +const banner = el("header", "lf-ui lf-banner"); const dot = el("span", "lf-dot"); const statusText = el("span", "lf-status-text", "Connecting…"); const bannerStatus = el("div", "lf-banner-status"); diff --git a/skills/leaf/assets/mcp-app.html b/skills/leaf/assets/mcp-app.html index db52ea0d2..8f012e782 100644 --- a/skills/leaf/assets/mcp-app.html +++ b/skills/leaf/assets/mcp-app.html @@ -252,9 +252,6 @@
-

Waiting for the Leaf page…

@@ -328,11 +325,10 @@ field.style.height = `${Math.min(Math.max(field.scrollHeight, 66), 240)}px`; } - function showStatus(text, { error = false, handoff = false } = {}) { + function showStatus(text, { error = false } = {}) { $("status-text").textContent = text; $("status").classList.toggle("show", Boolean(text)); $("status").classList.toggle("error", error); - $("handoff").hidden = !handoff; } function applyHostContext(update) { @@ -398,11 +394,6 @@ showStatus( "This host shows Leaf read-only. Use Full page to leave feedback.", ); - } else if (leaf.delivery) { - showStatus( - `Reader feedback through event ${leaf.deliverySeq} is waiting for Codex.`, - { handoff: true }, - ); } else { showStatus(""); } @@ -482,71 +473,18 @@ $("comment").addEventListener("input", sizeComment); $("comment-page").addEventListener("click", () => { - selection = null; - $("quote").textContent = "On this page"; - $("comment").placeholder = "Comment on this page"; + selection = captureSelection(); + $("quote").textContent = selection + ? `On “${selection.quote.length > 150 ? `${selection.quote.slice(0, 147)}…` : selection.quote}”` + : "On this page"; + $("comment").placeholder = selection + ? "Comment on this passage" + : "Comment on this page"; $("composer").classList.add("open"); sizeComment(); $("comment").focus(); }); - async function handoff() { - if (!snapshot?.delivery || busy) return; - busy = true; - $("handoff").disabled = true; - showStatus("Sending the durable feedback to Codex…"); - let contextAccepted = false; - try { - try { - await request("ui/update-model-context", { - content: [{ type: "text", text: snapshot.delivery }], - structuredContent: { - page: snapshot.page, - throughSeq: snapshot.deliverySeq, - }, - }); - contextAccepted = true; - } catch (_) {} - const intro = `Leaf reader feedback is ready for ${snapshot.page} through event ${snapshot.deliverySeq}. Process every event and continue the review.`; - await request("ui/message", { - role: "user", - content: [ - { - type: "text", - text: contextAccepted ? intro : `${intro}\n\n${snapshot.delivery}`, - }, - ], - }); - let contextClearFailed = false; - if (contextAccepted) { - try { - await request("ui/update-model-context", { content: [] }); - } catch (_) { - contextClearFailed = true; - } - } - await callTool("leaf_delivery_ack", { - page: snapshot.page, - seq: snapshot.deliverySeq, - }); - showStatus( - contextClearFailed - ? "Feedback sent to Codex; the host kept its temporary context." - : "Feedback sent to Codex.", - ); - } catch (error) { - showStatus( - `Feedback is saved, but the handoff failed: ${errorText(error)}`, - { error: true, handoff: true }, - ); - } finally { - busy = false; - $("handoff").disabled = false; - } - } - - $("handoff").addEventListener("click", handoff); - $("composer").addEventListener("submit", async (event) => { event.preventDefault(); if (!snapshot || busy) return; @@ -562,7 +500,7 @@ attempt: attemptId(), ...(selection && { anchor: selection }), }; - await callTool("leaf_apply_event", { + await callTool("leaf_snapshot_apply_event", { page: snapshot.page, view_revision: snapshot.revision, event: leafEvent, @@ -571,8 +509,9 @@ sizeComment(); $("composer").classList.remove("open"); selection = null; - busy = false; - await handoff(); + showStatus( + "Feedback saved in the Leaf log. The Codex adapter will deliver it.", + ); } catch (error) { showStatus(errorText(error), { error: true }); } finally { @@ -586,7 +525,7 @@ busy = true; $("refresh").disabled = true; try { - await callTool("leaf_refresh", { page: snapshot.page }); + await callTool("leaf_snapshot_refresh", { page: snapshot.page }); } catch (error) { showStatus(errorText(error), { error: true }); } finally { diff --git a/skills/leaf/assets/vendor/mcp-page-app.html b/skills/leaf/assets/vendor/mcp-page-app.html new file mode 100644 index 000000000..dfd8e8fa1 --- /dev/null +++ b/skills/leaf/assets/vendor/mcp-page-app.html @@ -0,0 +1,333 @@ + + + + + + + Leaf page + + + +
+
+
+ + Leaf + Opening page… +
+
+ + + +
+
+
+

Starting the Leaf page…

+ +
+
+ Waiting for Leaf + + +
+
+ + + diff --git a/skills/leaf/references/event-batches.md b/skills/leaf/references/event-batches.md index 3080036d7..1f3f9786b 100644 --- a/skills/leaf/references/event-batches.md +++ b/skills/leaf/references/event-batches.md @@ -31,11 +31,10 @@ retries the same pointer with the same Leaf delivery id. This is at-least-once delivery and may create a retry turn; the task applies the page-and-sequence retry rule below. -The Codex MCP App follows the same boundary without a wait process. It persists -one reader event, sends the complete pending batch with a host follow-up, and -advances the cursor only after the host accepts that turn. The app owns that -acknowledgement; the receiving task processes the batch directly and does not -run `leaf wait` or `leaf ack`. +An embedded MCP App changes where the page is drawn, not this carrier. Its events +enter the same append-only log; the detached Codex adapter still owns wait, +durable queue acceptance, and acknowledgement. A successful `ui/message` response +is not a delivery receipt. If direct wait output is truncated, acknowledge nothing and rerun with enough output capacity for the whole batch. After the complete batch reaches model diff --git a/skills/leaf/references/host-codex.md b/skills/leaf/references/host-codex.md index b6bfb205c..38cc7b405 100644 --- a/skills/leaf/references/host-codex.md +++ b/skills/leaf/references/host-codex.md @@ -2,64 +2,59 @@ Read this immediately before handing a page to Codex. -## Experimental embedded MCP App - -This path is merged for testing in installed Codex builds. Prefer it when it is -available so the experiment receives real use, but preserve the full browser -path and do not treat the merge as a commitment to keep or expand the embedded -surface. Leaf will decide whether to remove it, keep it compact, or build on it -after evaluating that use. - -Prefer the bundled Leaf MCP tool whose exposed name ends in -`leaf_present` when it is available **and the tool result visibly attaches an -app**. Call it with the page's absolute directory. The app labels and renders the -active authored revision with the current event and delivery cursors; it does -not claim to show the browser's log-projected widget state. It writes comments -through Leaf's ordinary browser-event validator into `comments.jsonl`. It does -not start a loopback server, create a watcher, or introduce another current-state -store. - -The embedded surface is deliberately a compact review, not a second copy of the -complete browser runtime. It renders the authored page and its local media, -supports page, passage, and element comments on text the file-side passage -reading can resolve, and can request fullscreen. Authored widget source is not -quotable and widget controls are inert. Start the browser path below when the user needs a -choice, drag, request, sign-off, or another package-owned interaction; also use -it when Codex returns only the tool's text fallback instead of rendering the app. -The app's **Full page** action asks Codex to start and open that browser path -when no live page URL is already available. - -After the app durably appends a comment, it sends the exact pending Leaf batch -into model context and asks Codex for a follow-up turn. Only after the host -accepts that turn does the app acknowledge the batch. The arriving turn sets the -page `working`, follows `event-batches.md`, processes every event, then hands the -page back to `waiting` or `idle`. Do not call the app-only write, refresh, or ack -tools from the model; the embedded UI owns them. +## Embedded MCP App + +Prefer the bundled model tool whose exposed name ends in `leaf_present`. Call it +with the initialized page's absolute directory. Continue only when its result +visibly attaches an app; a text result alone means this Codex build did not render +the surface. + +The attached app frames the complete canonical Leaf page: the same projected +document, package modules, comments, actions, versions, state stream, and event +endpoint as the browser handoff. The MCP process supplies one ephemeral localhost +origin, while each page receives a random capability path under it. That server is +presentation plumbing only. It creates no `service.json`, owns no current state, +and stops with the MCP session; the page directory and append-only log remain the +authorities. + +If the app shell appears but its page cannot load, use the model tool ending in +`leaf_present_snapshot` for a comments-only authored view, or follow the browser +fallback below when the user needs package controls. Do not call the app-only +refresh or write tools from the model. + +The app does not claim that a host's `ui/message` response delivered anything to +Codex. Before finishing the handoff, set the page to `waiting` and run `leaf codex +start `. The detached adapter is the sole wake and acknowledgement carrier: +it queues the persisted batch into a later turn of this task and advances the +cursor only after Codex accepts that durable queue item. Then finish the turn by +saying the Leaf page is attached; an embedded page has no durable URL to invent. ## Full browser fallback -Run `leaf server start ` and retain its exact URL. After setting the page -`waiting`, run `leaf codex start `, then finish the turn normally with that -URL. One detached adapter watches every page this task owns. It gives -each complete batch a stable delivery id, queues it as a new user turn in this -same task, and acknowledges only after Codex accepts the durable queue item. - -The loaded Desktop client starts that later turn and keeps ownership of -execution and approvals. If the task has been unloaded, the item stays queued -until Codex reopens it; the adapter never resumes the task or answers client -requests on the user's behalf. The small queued message is a `leaf-delivery` XML -element pointing to the exact persisted batch rather than copying an arbitrarily -large batch into Codex's bounded text input. A later queued turn reads that -payload, processes it directly, and leaves only `leaf wait` and `leaf ack` to the -adapter. The turn still owns replies, revisions, and page status, including the -handoff back to `waiting` or `idle`. Starting the command again for another page -adds that page to the same task-wide watch. - -If `leaf codex start` refuses to start, do not finish over a live page. Follow -its diagnostic: an existing foreground `leaf wait` must be stopped before the -adapter can take the task's single wait lease, and an unavailable Codex queue -command cannot receive later turns. +Use this path when `leaf_present` returns only text, when the host refuses the +nested local page, or when the user asks to open Leaf separately. Run `leaf server +start ` and retain its exact URL. After setting the page `waiting`, run `leaf +codex start `, then finish the turn normally with that URL. + +One detached adapter watches every page this task owns. It gives each complete +batch a stable delivery id, queues it as a new user turn in this same task, and +acknowledges only after Codex accepts the durable queue item. Starting the command +again for another page adds that page to the same task-wide watch. + +The loaded Desktop client starts that later turn and keeps ownership of execution +and approvals. If the task has been unloaded, the item stays queued until Codex +reopens it; the adapter never resumes the task or answers client requests on the +user's behalf. The small queued message is a `leaf-delivery` XML element pointing +to the exact persisted batch rather than copying an arbitrarily large batch into +Codex's bounded text input. The later turn reads that payload and leaves only +`leaf wait` and `leaf ack` to the adapter. It still owns replies, revisions, page +status, and the handoff back to `waiting` or `idle`. + +If `leaf codex start` refuses to start, do not finish over a live page. Follow its +diagnostic: an existing foreground `leaf wait` must be stopped before the adapter +can take the task's single wait lease, and an unavailable Codex queue command +cannot receive later turns. An optional separate Codex watcher remains a fallback that requires the user's -explicit authorization because it creates a visible task. Its route is in the -main skill. +explicit authorization because it creates a visible task. Its route is in the main +skill. diff --git a/skills/leaf/references/internals/mcp-app.md b/skills/leaf/references/internals/mcp-app.md index df5f3fc6e..30832e017 100644 --- a/skills/leaf/references/internals/mcp-app.md +++ b/skills/leaf/references/internals/mcp-app.md @@ -1,54 +1,81 @@ # MCP App transport -Status: experimental. This transport is merged to test the embedded review in -installed Codex builds. Its inclusion does not commit Leaf to retaining it or -expanding it toward browser parity; that decision follows from real use. - -Leaf's bundled stdio server is a host transport over the existing page model. -It never owns current state: `index.html` starts state, immutable revisions hold -valid saves, `comments.jsonl` changes them, and the page transaction remains the -only read/write boundary. `PageStateService` is shared by HTTP and MCP so those -transports cannot drift into different projections. - -The Codex manifest points at `.codex-plugin/mcp.json`, which launches `bin/leaf -mcp` from the installed plugin root. Keeping the config off the root `.mcp.json` -path prevents Claude Code from auto-registering this Codex transport. The server -exposes one model-visible presentation tool, -`leaf_present`, and one `ui://leaf/review/v1.html` resource with -`text/html;profile=mcp-app`. The tool's ordinary content and structured result -contain only a small summary. Authored HTML, theme, current projection basis, -and the pending event batch live under result `_meta.leaf`, which the host sends -to the app but does not put in model context. The card identifies this as an -authored snapshot; projected widgets, threads, and package interactions belong -to the full browser. - -The compact app always exposes **Full page**. It opens the live page through -`ui/open-link` when a server URL is present and the host supports links; -otherwise it uses `ui/message` to ask the task to start the full browser flow. - -Three app-visible tools complete the loop: - -- `leaf_apply_event` sends one attempt-identified event through `EventEndpoint`; -- `leaf_refresh` takes a fresh authoritative snapshot; -- `leaf_delivery_ack` advances the cursor after the host accepts a follow-up. - -The app must persist an event before asking the host for a new turn. It first -offers the exact batch through `ui/update-model-context`, then calls `ui/message`; -if the host refuses hidden context, it includes the batch in that message. It -best-effort clears accepted model context after that follow-up, then acknowledges -once the follow-up itself is accepted. A clear refusal cannot turn a known delivery -into a retry and duplicate the turn. A failed follow-up leaves the batch pending and -offers a retry. This is the same at-least-once page-and-sequence contract as the -detached adapter. - -The resource is self-contained and declares no network or external-resource -domains. It strips executable document content, uses a shadow root for authored -markup and theme rules, disables page controls, and supports comments only. Do -not point an iframe at Leaf's dynamic loopback URL or grant a wildcard CSP to -recover the full runtime. Package-owned actions stay in the browser until the -runtime has a transport-independent bundle rather than a second implementation. -A selection here is resolved by nothing, so this transport asks the door to -capture it: passage comments are read under the append transaction against Leaf's -file-side current passage reading, and widget source or any anchor that would -detach is refused before append. The served runtime's own anchors keep their -browser reading and are not re-read this way. +Status: experimental. The Codex plugin registers this transport so the complete +Leaf interface can be evaluated in shipped MCP Apps hosts without replacing the +browser fallback. + +## Authorities + +The bundled stdio server is a delivery route over the existing page model. +`index.html` starts state, immutable revisions hold valid saves, +`comments.jsonl` changes them, and `PageTransaction` remains the read/write +boundary. The MCP resource, its iframe shell, and its loopback server own no +current state, replay, undo, versions, or delivery cursor. Closing an app loses +only that presentation. + +The Codex manifest launches `bin/leaf mcp` from the installed plugin root. The +server advertises `text/html;profile=mcp-app` and two resources: + +- `ui://leaf/page/v1.html` is the primary complete page; +- `ui://leaf/review/v1.html` is an explicit comments-only snapshot fallback. + +## Complete page + +`leaf_present` is model-visible and binds the complete resource. `leaf_refresh` +is app-only. Both activate and read the named initialized page using +`PageStateService`; presentation can therefore create a valid immutable revision +and does not advertise the read-only hint. The ordinary text and structured +result carry a small summary. The local frame address and optional ordinary +browser URL live only under `_meta.leaf`, which the host sends to the app without +adding it to model context. + +One `ProcessPageServer` binds `127.0.0.1` on an ephemeral port before the resource +is registered. Its exact `http://localhost:` origin is the resource's sole +`frameDomains` entry. Every opened page receives an unguessable +`/p//` path on that origin; the process reuses a page's path, writes +no `service.json`, and drops every path when it exits. There is no wildcard CSP, +query token, cookie, or durable host key in the tool result. + +The canonical page contract speaks root-relative Leaf routes. At this multiplexing +boundary, every HTML response and validated frozen `markup` value in state passes +through document route scoping, textual served assets scope their known routes, and +version URLs in state receive the page prefix. Together these adapt `api`, `runtime`, +`widgets`, `vendor`, `media`, registry, theme, icon, and runtime paths below the +capability. This keeps arbitrary package modules on the ordinary +`/runtime/widget-api.js` contract while ensuring every subsequent request proves the +same page capability. Unknown or unscoped paths receive 404. The nested frame +therefore runs the same authored document, package modules, comments, actions, +versions, state stream, and `EventEndpoint` as an ordinary Leaf tab. + +This route is local-host-only. The browser rendering the MCP App must share the +machine running the stdio server. A remote page address requires an explicit +deployment and security contract rather than a widened loopback server. + +## Snapshot fallback + +`leaf_present_snapshot` is a separate model-visible fallback, not the primary +interface and not a compact vocabulary projector. It renders inert authored +markup and theme rules, supports page, element, and passage comments, and offers +the ordinary browser route when one exists. `leaf_snapshot_apply_event` and +`leaf_snapshot_refresh` are app-only. The append gate canonicalizes abbreviated +text anchors before storing them, so fallback clients do not create weaker +durable anchors. Served runtime anchors keep the browser reading that already +resolved them; only the snapshot endpoint requests file-side capture. + +The resource is self-contained, runs no authored code, and declares no network +domains. It does not implement package actions. The old single-choice +`lf-options` projector is deliberately absent: a fixed shape was a second Leaf +interface and understated what the complete route can carry. + +## Return and wake + +Both surfaces persist reader gestures before reporting success. Neither calls +`ui/message` to claim delivery or advances the event cursor from an MCP response: +the host may accept that JSON-RPC request without starting or durably queueing a +turn. `leaf codex start ` remains the authoritative return carrier. Its +detached adapter waits on the same log, queues an exact persisted batch into the +same Codex task, and acknowledges only after durable queue acceptance. + +The complete and snapshot resources are tracked bundles generated from +`scripts/mcp-app/` by `scripts/vendor.py mcp-app`; do not patch the generated HTML +under `assets/vendor/` directly. diff --git a/skills/leaf/scripts/CLAUDE.md b/skills/leaf/scripts/CLAUDE.md index 1d75922bf..4b0c2ec23 100644 --- a/skills/leaf/scripts/CLAUDE.md +++ b/skills/leaf/scripts/CLAUDE.md @@ -24,6 +24,7 @@ The main owners are: - `acknowledgments` and `work`: growing delivery receipts, transient work claims, and widget work seats; - `decisions`: declaration-driven page and thread decision projections; +- `mcp_page`: the capability-scoped canonical page server and private MCP result; - `requests`: declared request seats, their canonical lifecycle, and the terminal host receipts that close one; - `host`: local paths, process readings, host identity, and session lifetime; @@ -32,8 +33,8 @@ The main owners are: - `server` and `hosting`: server address and lifetime state, and the HTTP process; - `session` and `hooks`: direct wait delivery and host lifecycle; - `codex`: detached Codex queue delivery and recovery; -- `mcp_server` and `mcp_app`: the bundled MCP transport, app resource, private - page snapshot, and host handoff; +- `mcp_server` and `mcp_app`: the bundled MCP transport and comments-only + snapshot fallback; - `presence`: page, claim, and neighboring-leaf presence readings; - `served_state/` and `http`: browser-facing projections and change readings, and HTTP transport; @@ -67,8 +68,8 @@ Read the reference that owns the boundary before changing it: service lifetime; - `../references/internals/validation.md` for static checks, browser checks, parsed source, and file-side passages. -- `../references/internals/mcp-app.md` for MCP tools, resource metadata, host - messages, and delivery acknowledgement. +- `../references/internals/mcp-app.md` for MCP tools, resource metadata, the + process-scoped page server, snapshot fallback, and Codex return carrier. `../references/packages.md` owns the public package contract. The browser's parallel projection, passage, registry, and render rules live in `../CLAUDE.md`. diff --git a/skills/leaf/scripts/leaf/event_endpoint.py b/skills/leaf/scripts/leaf/event_endpoint.py index 2a472a436..7c0f55792 100644 --- a/skills/leaf/scripts/leaf/event_endpoint.py +++ b/skills/leaf/scripts/leaf/event_endpoint.py @@ -47,6 +47,21 @@ def _accepted_retry( """Read an attempted retry before mutable-state validation.""" if "attempt" not in event: return False, None + # The append gate enriches an abbreviated text anchor with its canonical + # context. A later retry still carries the original browser payload, so compare + # it as that same payload rather than treating server-added fields as a conflict. + existing = next( + (logged for logged in page.events if logged.get("attempt") == event["attempt"]), + None, + ) + if ( + existing + and event.get("kind") == existing.get("kind") == "comment" + and isinstance(event.get("anchor"), dict) + and isinstance(existing.get("anchor"), dict) + ): + for field, value in existing["anchor"].items(): + event["anchor"].setdefault(field, value) event["author"] = "user" try: existing = page.matching_attempt(event) @@ -195,7 +210,7 @@ def anchored_comment_rejection(self) -> EventAnswer | None: html, self.events, registry, self.event["revision"] ) try: - capture_anchor( + canonical = capture_anchor( html, registry, anchor.get("quote", ""), @@ -210,6 +225,16 @@ def anchored_comment_rejection(self) -> EventAnswer | None: self.event, f"comment anchor is not in the current page reading: {error}", ) + for field in ("quote",): + if field in anchor and anchor[field] != canonical.get(field): + return event_rejection( + self.event, + f"comment anchor {field} does not match the current page reading", + ) + # Store the file-side reading, not the client's abbreviated proof. Compact + # clients may name only quote and section; capture adds the context needed to + # keep that passage attached when the same words occur elsewhere later. + self.event["anchor"] = canonical return None def parent_rejection(self) -> EventAnswer | None: diff --git a/skills/leaf/scripts/leaf/http.py b/skills/leaf/scripts/leaf/http.py index ebe41b292..8060f7c86 100644 --- a/skills/leaf/scripts/leaf/http.py +++ b/skills/leaf/scripts/leaf/http.py @@ -1,6 +1,7 @@ """HTTP transport and routes for one served page.""" import json +import re import secrets import select import time @@ -59,7 +60,120 @@ PRESENCE_S = 2.0 -def runtime_document(source: str, revision: int, version: int | None = None) -> bytes: +_ROOTED_PAGE_ROUTE = re.compile( + rb'(?P["\'`(])/(?P' + rb"(?:api|runtime|widgets|vendor|media)/|" + rb"(?:registry\.json|theme\.css|icon\.svg|leaf\.js)" + rb")" +) + +_ROOTED_PAGE_ATTRIBUTE = re.compile( + rb'(?P=\s*["\'])/(?P' + rb"(?:api|runtime|widgets|vendor|media)/|" + rb"(?:registry\.json|theme\.css|icon\.svg|leaf\.js)" + rb")", + re.IGNORECASE, +) +_HTML_START_TAG = re.compile(rb"<[A-Za-z](?:[^<>\"']|\"[^\"]*\"|'[^']*')*>", re.DOTALL) +_STYLE_ATTRIBUTE = re.compile( + rb'(?P\bstyle\s*=\s*)(?P["\'])(?P.*?)(?P=quote)', + re.IGNORECASE | re.DOTALL, +) +_STYLE_ELEMENT = re.compile( + rb"(?P\"']|\"[^\"]*\"|'[^']*')*>)" + rb"(?P.*?)(?P)", + re.IGNORECASE | re.DOTALL, +) + + +def scope_page_routes(body: bytes, page_root: str) -> bytes: + """Put Leaf's canonical root routes below one delivery capability path. + + Package modules intentionally speak the same root-relative browser contract as + the kernel. The process-scoped MCP server multiplexes pages on one origin, so it + adapts those known routes at its HTTP boundary instead of making packages learn a + second addressing convention. + """ + if not page_root: + return body + root = page_root.rstrip("/").encode() + return _ROOTED_PAGE_ROUTE.sub( + lambda match: match.group("before") + root + b"/" + match.group("path"), + body, + ) + + +def scope_document_routes(body: bytes, page_root: str) -> bytes: + """Scope only route-bearing HTML attributes in an authored document. + + Authored prose is also the anchorable record. A route-looking phrase in that + prose must therefore remain byte-for-byte identical to the immutable revision, + while actual browser addresses still need the process page capability. + """ + if not page_root: + return body + root = page_root.rstrip("/").encode() + + def scope_routes(value: bytes) -> bytes: + return _ROOTED_PAGE_ROUTE.sub( + lambda match: match.group("before") + root + b"/" + match.group("path"), + value, + ) + + def scope_start_tag(tag_match: re.Match) -> bytes: + tag = _ROOTED_PAGE_ATTRIBUTE.sub( + lambda match: match.group("before") + root + b"/" + match.group("path"), + tag_match.group(), + ) + return _STYLE_ATTRIBUTE.sub( + lambda match: ( + match.group("before") + + match.group("quote") + + scope_routes(match.group("value")) + + match.group("quote") + ), + tag, + ) + + scoped = _HTML_START_TAG.sub(scope_start_tag, body) + return _STYLE_ELEMENT.sub( + lambda match: ( + match.group("open") + + scope_routes(match.group("value")) + + match.group("close") + ), + scoped, + ) + + +def scope_page_urls(value, page_root: str): + """Scope the canonical version addresses in a multiplexed state response.""" + if not page_root: + return value + if isinstance(value, list): + return [scope_page_urls(item, page_root) for item in value] + if not isinstance(value, dict): + return value + scoped = {} + for key, item in value.items(): + if ( + key == "url" + and isinstance(item, str) + and item.startswith(("/versions/", "/revisions/")) + ): + scoped[key] = page_root.rstrip("/") + item + elif key == "markup" and isinstance(item, str): + scoped[key] = scope_document_routes(item.encode(), page_root).decode() + else: + scoped[key] = scope_page_urls(item, page_root) + return scoped + + +def runtime_document( + source: str, + revision: int, + version: int | None = None, +) -> bytes: """Inject the exact immutable identity beside the canonical runtime script.""" parsed = parse_structure(source) scripts = [ @@ -91,6 +205,9 @@ class Handler(BaseHTTPRequestHandler): # Every server a user reaches exposes noted versions only. preview_upto = None preview_source = None + # Empty on the ordinary one-page server. The MCP delivery server sets this to + # an unguessable `/p/` prefix and rewrites only Leaf-owned routes. + page_root = "" def versions_live(self, events): if self.preview_upto is None: @@ -286,6 +403,12 @@ def end_headers(self): super().end_headers() def _send(self, status: int, ctype: str, body: bytes) -> None: + if ctype.startswith("text/html"): + body = scope_document_routes(body, self.page_root) + elif ctype.startswith( + ("text/css", "text/javascript", "application/javascript") + ): + body = scope_page_routes(body, self.page_root) self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) @@ -297,7 +420,11 @@ def _send(self, status: int, ctype: str, body: bytes) -> None: def _json(self, obj, status: int = 200) -> None: self._send( - status, "application/json", json.dumps(obj, ensure_ascii=False).encode() + status, + "application/json", + json.dumps( + scope_page_urls(obj, self.page_root), ensure_ascii=False + ).encode(), ) def do_GET(self): diff --git a/skills/leaf/scripts/leaf/mcp_app.py b/skills/leaf/scripts/leaf/mcp_app.py index 34a8f9e8e..d11ca0dcd 100644 --- a/skills/leaf/scripts/leaf/mcp_app.py +++ b/skills/leaf/scripts/leaf/mcp_app.py @@ -12,8 +12,6 @@ from .registry.storage import layer_generation from .schema import SKILL_ROOT from .served_state.service import PageStateService -from .service import PageTransaction, unacknowledged -from .session import PageTick, acknowledge, batch_jsonl from .structure import parse_structure APP_URI = "ui://leaf/review/v1.html" @@ -52,24 +50,6 @@ def state_service(page_dir: Path) -> PageStateService: return PageStateService(page_dir) -def pending_delivery(page_dir: Path) -> tuple[str | None, int | None]: - with PageTransaction(page_dir) as page: - pending = unacknowledged(page.events, page.cursor) - if not pending: - return None, None - reading = PageTick( - page_dir=page_dir, - status=page.status, - batch=pending, - live=page.status["state"] != "idle", - watch_state="watching", - lost=False, - restarted=None, - transaction=page, - ) - return batch_jsonl(reading), pending[-1]["seq"] - - def app_snapshot(page: str) -> tuple[dict, dict]: page_dir = resolve_page(page) state = state_service(page_dir).page_state() @@ -86,7 +66,6 @@ def app_snapshot(page: str) -> tuple[dict, dict]: theme, dark_theme = split_theme( (page_dir / "theme.css").read_text(encoding="utf-8") ) - delivery, delivery_seq = pending_delivery(page_dir) server = state.get("server") or {} summary = { "page": str(page_dir), @@ -102,8 +81,6 @@ def app_snapshot(page: str) -> tuple[dict, dict]: "authoredCss": inline_css_assets(parsed.css, page_dir), "theme": theme, "darkTheme": dark_theme, - "delivery": delivery, - "deliverySeq": delivery_seq, } return summary, private @@ -177,15 +154,5 @@ def apply_event(page: str, event: dict, view_revision: int | None) -> CallToolRe return result -def ack_delivery(page: str, seq: int) -> CallToolResult: - page_dir = resolve_page(page) - with PageTransaction(page_dir) as transaction: - acknowledge(transaction, seq) - return result_for_page( - str(page_dir), - message=f"Leaf acknowledged the delivered batch through event {seq}.", - ) - - def app_html() -> str: return (SKILL_ROOT / "assets" / "mcp-app.html").read_text(encoding="utf-8") diff --git a/skills/leaf/scripts/leaf/mcp_page.py b/skills/leaf/scripts/leaf/mcp_page.py new file mode 100644 index 000000000..8ba39a867 --- /dev/null +++ b/skills/leaf/scripts/leaf/mcp_page.py @@ -0,0 +1,233 @@ +"""Canonical Leaf pages delivered through one process-scoped MCP App origin.""" + +from __future__ import annotations + +import secrets +import threading +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlsplit + +from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import CallToolResult, TextContent + +from .event_endpoint import EventEndpoint +from .files import revision_path +from .hosting import server_at +from .http import Handler +from .registry.storage import layer_metadata +from .schema import ASSETS +from .served_state.service import PageStateService +from .server import preview_metadata, running_server +from .structure import parse_structure + +PAGE_RESOURCE_URI = "ui://leaf/page/v1.html" +PAGE_APP_RESOURCE = ASSETS / "vendor" / "mcp-page-app.html" +PAGE_FORMAT = "leaf.page/v1" + + +@dataclass +class _PageSession: + page_dir: Path + capability: str + endpoint: EventEndpoint + layer_identity: dict + preview: dict | None + + +class _RoutedPageHandler(Handler): + """Select a page from an unguessable path before entering the HTTP boundary.""" + + router: ProcessPageServer + protocol_version = "HTTP/1.1" + layer = "" + + def authorized(self) -> bool: + # `_select_page` already proved possession of the process-scoped capability. + return True + + def _select_page(self) -> bool: + external = urlsplit(self.path) + parts = external.path.split("/", 3) + if len(parts) < 3 or parts[1] != "p" or not parts[2]: + return False + session = self.router.session(parts[2]) + if session is None: + return False + inside = f"/{parts[3]}" if len(parts) == 4 and parts[3] else "/" + self.page_dir = session.page_dir + self.event_endpoint = session.endpoint + self.layer = session.layer_identity["generation"] + self.layer_identity = session.layer_identity + self.preview = session.preview + self.page_root = f"/p/{session.capability}" + self.path = inside + (f"?{external.query}" if external.query else "") + return True + + def _unknown_capability(self) -> None: + # A POST body has not been consumed yet, so this HTTP/1.1 connection cannot + # safely be reused after the refusal. + if self.command == "POST": + self.close_connection = True + self._json({"error": "not found"}, 404) + + def _select_or_answer(self) -> bool | None: + """Select a page, answering route faults before the shared handler exists.""" + try: + return self._select_page() + except Exception as error: # noqa: BLE001 - this is the outer route boundary + if self.command == "POST": + self.close_connection = True + try: + self._json({"error": f"{type(error).__name__}: {error}"}, 500) + except OSError: + pass + return None + + def do_GET(self): + selected = self._select_or_answer() + if selected is None: + return + if not selected: + self._unknown_capability() + return + super().do_GET() + + def do_POST(self): + selected = self._select_or_answer() + if selected is None: + return + if not selected: + self._unknown_capability() + return + super().do_POST() + + +class ProcessPageServer: + """Serve every page opened by one MCP process from one exact local origin. + + The HTTP server is transport plumbing only. Each page remains a directory plus + append-only log, while its random path is a bearer capability that expires with + this process and is never written into the page. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._by_capability: dict[str, _PageSession] = {} + self._by_page: dict[Path, _PageSession] = {} + handler = type("MCPPageHandler", (_RoutedPageHandler,), {"router": self}) + self._httpd = server_at("127.0.0.1", 0, handler) + self._httpd.daemon_threads = True + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + self._closed = False + + @property + def origin(self) -> str: + return f"http://localhost:{self._httpd.server_address[1]}" + + def open(self, page_dir: Path) -> str: + page_dir = page_dir.resolve() + with self._lock: + session = self._by_page.get(page_dir) + if session is None: + capability = secrets.token_urlsafe(24) + identity = layer_metadata(page_dir) + session = _PageSession( + page_dir=page_dir, + capability=capability, + endpoint=EventEndpoint(page_dir), + layer_identity=identity, + preview=preview_metadata(page_dir), + ) + self._by_page[page_dir] = session + self._by_capability[capability] = session + return f"{self.origin}/p/{session.capability}/" + + def session(self, capability: str) -> _PageSession | None: + with self._lock: + session = self._by_capability.get(capability) + if session is None: + return None + current_identity = layer_metadata(session.page_dir) + if current_identity != session.layer_identity: + session.layer_identity = current_identity + session.endpoint = EventEndpoint(session.page_dir) + return session + + def close(self) -> None: + with self._lock: + if self._closed: + return + self._closed = True + self._by_capability.clear() + self._by_page.clear() + self._httpd.shutdown() + self._httpd.server_close() + self._thread.join(timeout=2) + + +def resolve_page(page: str | Path) -> Path: + """Resolve one initialized page without letting presentation mint its log.""" + page_dir = Path(page).expanduser().resolve() + if not (page_dir / "comments.jsonl").is_file(): + raise ToolError( + f"{page_dir} is not an initialized Leaf page; run `leaf page init` first" + ) + return page_dir + + +def page_state(page: str | Path, pages: ProcessPageServer) -> tuple[dict, dict]: + """Return a model-sized summary and the app-private canonical page address.""" + page_dir = resolve_page(page) + state = PageStateService( + page_dir, + layer_identity=layer_metadata(page_dir), + preview=preview_metadata(page_dir), + ).page_state() + active = state.get("active") + if active is None: + raise ToolError( + f"{page_dir} has no active revision; write a valid index.html first" + ) + server = running_server(page_dir) or {} + source = revision_path(page_dir, active["revision"]).read_text(encoding="utf-8") + title = parse_structure(source).title.strip() or page_dir.name + summary = { + "format": PAGE_FORMAT, + "mode": "page", + "page": str(page_dir), + "title": title, + "active": active, + "event_seq": state["browser"]["basis"]["through_seq"], + "source_error": state["source_error"], + } + private = { + **summary, + "inline_url": pages.open(page_dir), + "browser_url": server.get("url"), + "message": ( + "Opening the last valid revision; the current authored source is invalid." + if summary["source_error"] + else "Opening the complete Leaf page." + ), + } + return summary, private + + +def page_result(page: str | Path, pages: ProcessPageServer) -> CallToolResult: + summary, private = page_state(page, pages) + text = ( + f"Leaf page {summary['title']!r} is ready at " + f"{summary['active']['label']}. The page directory and append-only event " + "log remain authoritative." + ) + return CallToolResult( + content=[TextContent(type="text", text=text)], + structuredContent=summary, + _meta={"leaf": private}, + ) + + +def page_app_html() -> str: + return PAGE_APP_RESOURCE.read_text(encoding="utf-8") diff --git a/skills/leaf/scripts/leaf/mcp_server.py b/skills/leaf/scripts/leaf/mcp_server.py index 9d172fe70..ad49f3df4 100644 --- a/skills/leaf/scripts/leaf/mcp_server.py +++ b/skills/leaf/scripts/leaf/mcp_server.py @@ -1,10 +1,20 @@ -"""Bundled stdio MCP server and MCP Apps resource.""" +"""Bundled stdio MCP server and Leaf's MCP Apps resources.""" + +from __future__ import annotations + +import atexit from mcp.server.apps import APP_MIME_TYPE, Apps, ResourceCsp from mcp.server.mcpserver import MCPServer from mcp.types import ToolAnnotations -from .mcp_app import APP_URI, ack_delivery, app_html, apply_event, result_for_page +from .mcp_app import APP_URI, app_html, apply_event, result_for_page +from .mcp_page import ( + PAGE_RESOURCE_URI, + ProcessPageServer, + page_app_html, + page_result, +) class LeafApps(Apps): @@ -14,26 +24,46 @@ def settings(self) -> dict: return {"mimeTypes": [APP_MIME_TYPE]} -def make_mcp_server() -> MCPServer: +def make_mcp_server( + pages: ProcessPageServer | None = None, + *, + page_html: str | None = None, + snapshot_html: str | None = None, +) -> MCPServer: + """Build one registered server over the canonical page and a small fallback.""" + pages = pages or ProcessPageServer() + atexit.register(pages.close) apps = LeafApps() + apps.add_html_resource( + PAGE_RESOURCE_URI, + page_html if page_html is not None else page_app_html(), + name="Leaf page", + description="The complete canonical Leaf browser interface in an MCP App.", + csp=ResourceCsp( + connect_domains=[], + resource_domains=[], + frame_domains=[pages.origin], + ), + prefers_border=False, + ) apps.add_html_resource( APP_URI, - app_html(), - name="Leaf review (experimental)", - description="Experimental interactive review and anchored feedback for a Leaf page.", + snapshot_html if snapshot_html is not None else app_html(), + name="Leaf review snapshot", + description="A compact authored snapshot for hosts that cannot frame the page.", csp=ResourceCsp(connect_domains=[], resource_domains=[]), prefers_border=False, ) @apps.tool( - resource_uri=APP_URI, + resource_uri=PAGE_RESOURCE_URI, visibility=["model"], name="leaf_present", title="Present Leaf page (experimental)", description=( - "Present an initialized Leaf page in the experimental compact review. Use " - "the absolute page directory created by `leaf page init`. The text result " - "remains useful when this client cannot render MCP Apps UI." + "Present the complete canonical interface for an initialized Leaf page. " + "Use the absolute page directory created by `leaf page init`. The text " + "result remains useful when this client cannot render MCP Apps UI." ), annotations=ToolAnnotations( readOnlyHint=False, @@ -44,14 +74,14 @@ def make_mcp_server() -> MCPServer: structured_output=False, ) def leaf_present(page: str): - return result_for_page(page) + return page_result(page, pages) @apps.tool( - resource_uri=APP_URI, + resource_uri=PAGE_RESOURCE_URI, visibility=["app"], - name="leaf_apply_event", - title="Apply Leaf reader event", - description="Validate and durably append one reader event from the Leaf app.", + name="leaf_refresh", + title="Refresh Leaf page", + description="Read the current canonical Leaf page address and summary.", annotations=ToolAnnotations( readOnlyHint=False, destructiveHint=False, @@ -60,31 +90,35 @@ def leaf_present(page: str): ), structured_output=False, ) - def leaf_apply_event(page: str, event: dict, view_revision: int | None = None): - return apply_event(page, event, view_revision) + def leaf_refresh(page: str): + return page_result(page, pages) @apps.tool( resource_uri=APP_URI, - visibility=["app"], - name="leaf_refresh", - title="Refresh Leaf page", - description="Read the current authoritative Leaf page projection.", + visibility=["model"], + name="leaf_present_snapshot", + title="Present Leaf snapshot (fallback)", + description=( + "Present an authored, comments-only Leaf snapshot when the complete page " + "cannot be framed by this host. Prefer leaf_present." + ), annotations=ToolAnnotations( readOnlyHint=False, destructiveHint=False, + idempotentHint=True, openWorldHint=False, ), structured_output=False, ) - def leaf_refresh(page: str): + def leaf_present_snapshot(page: str): return result_for_page(page) @apps.tool( resource_uri=APP_URI, visibility=["app"], - name="leaf_delivery_ack", - title="Acknowledge Leaf delivery", - description="Acknowledge a complete Leaf batch after the host accepts its follow-up.", + name="leaf_snapshot_apply_event", + title="Apply Leaf snapshot comment", + description="Validate and durably append one reader comment from the snapshot.", annotations=ToolAnnotations( readOnlyHint=False, destructiveHint=False, @@ -93,17 +127,40 @@ def leaf_refresh(page: str): ), structured_output=False, ) - def leaf_delivery_ack(page: str, seq: int): - return ack_delivery(page, seq) + def leaf_snapshot_apply_event( + page: str, event: dict, view_revision: int | None = None + ): + return apply_event(page, event, view_revision) + + @apps.tool( + resource_uri=APP_URI, + visibility=["app"], + name="leaf_snapshot_refresh", + title="Refresh Leaf snapshot", + description="Read the current authored Leaf snapshot.", + annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + openWorldHint=False, + ), + structured_output=False, + ) + def leaf_snapshot_refresh(page: str): + return result_for_page(page) - return MCPServer( + server = MCPServer( "leaf", title="Leaf", - description="Present and continue experimental interactive Leaf review pages.", + description="Present and continue complete Leaf review pages.", version="1", extensions=[apps], ) + return server def run_mcp_server() -> None: - make_mcp_server().run(transport="stdio") + pages = ProcessPageServer() + try: + make_mcp_server(pages).run(transport="stdio") + finally: + pages.close() diff --git a/tests/test_interact_mcp.py b/tests/test_interact_mcp.py index 86f444447..cca2141f3 100644 --- a/tests/test_interact_mcp.py +++ b/tests/test_interact_mcp.py @@ -7,13 +7,14 @@ from pathlib import Path from leaf import event_log as events_model -from leaf.mcp_app import APP_MIME, APP_URI, ack_delivery, app_snapshot, apply_event +from leaf.mcp_app import APP_MIME, APP_URI, app_snapshot, apply_event +from leaf.mcp_page import PAGE_RESOURCE_URI, ProcessPageServer from leaf.mcp_server import make_mcp_server from mcp import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client -def test_mcp_event_round_trip_is_durable_retryable_and_acknowledged(page_dir): +def test_snapshot_event_round_trip_is_durable_retryable_and_canonical(page_dir): candidate = { "kind": "comment", "revision": 1, @@ -30,15 +31,11 @@ def test_mcp_event_round_trip_is_durable_retryable_and_acknowledged(page_dir): assert len(events_model.read_events(page_dir)) == 1 accepted = first.structured_content["accepted"] assert accepted["attempt"] == "mcp-comment-one-1" - private = repeated.meta["leaf"] - delivery = [json.loads(line) for line in private["delivery"].splitlines()] - assert delivery[0]["page"] == str(page_dir) - assert delivery[1] == events_model.read_events(page_dir)[0] - - acknowledged = ack_delivery(str(page_dir), private["deliverySeq"]) - - assert acknowledged.meta["leaf"]["delivery"] is None - assert events_model.read_events(page_dir)[0]["text"] == candidate["text"] + stored = events_model.read_events(page_dir)[0] + assert stored["text"] == candidate["text"] + assert stored["anchor"]["quote"] == "The cutoff lives in" + assert stored["anchor"]["section"] == "plan" + assert stored["anchor"]["suffix"] def test_mcp_write_requires_attempt_identity(page_dir): @@ -102,9 +99,13 @@ def test_codex_manifest_launches_the_bundled_server(): "mcpServers": {"leaf": {"command": "./bin/leaf", "args": ["mcp"], "cwd": "."}} } - capabilities = make_mcp_server()._lowlevel_server.get_capabilities( - protocol_version="2026-07-28" - ) + pages = ProcessPageServer() + try: + capabilities = make_mcp_server(pages)._lowlevel_server.get_capabilities( + protocol_version="2026-07-28" + ) + finally: + pages.close() assert capabilities.extensions == { "io.modelcontextprotocol/ui": {"mimeTypes": [APP_MIME]} } @@ -128,7 +129,7 @@ async def exchange(): initialized = await session.initialize() tools = await session.list_tools() resources = await session.list_resources() - resource = await session.read_resource(APP_URI) + resource = await session.read_resource(PAGE_RESOURCE_URI) result = await session.call_tool("leaf_present", {"page": str(page_dir)}) return initialized, tools, resources, resource, result @@ -138,23 +139,27 @@ async def exchange(): assert initialized.protocol_version == "2025-11-25" assert set(by_name) == { "leaf_present", - "leaf_apply_event", "leaf_refresh", - "leaf_delivery_ack", + "leaf_present_snapshot", + "leaf_snapshot_apply_event", + "leaf_snapshot_refresh", } assert by_name["leaf_present"].meta == { - "ui": {"resourceUri": APP_URI, "visibility": ["model"]} + "ui": {"resourceUri": PAGE_RESOURCE_URI, "visibility": ["model"]} } assert by_name["leaf_present"].annotations.read_only_hint is False - assert by_name["leaf_apply_event"].meta["ui"] == { + assert by_name["leaf_snapshot_apply_event"].meta["ui"] == { "resourceUri": APP_URI, "visibility": ["app"], } - assert str(resources.resources[0].uri) == APP_URI - assert resources.resources[0].mime_type == APP_MIME + assert {str(item.uri) for item in resources.resources} == { + PAGE_RESOURCE_URI, + APP_URI, + } assert resource.contents[0].mime_type == APP_MIME assert "ui/initialize" in resource.contents[0].text assert result.is_error is False assert result.structured_content["title"] == "t" assert "document" not in result.structured_content - assert "document" in result.meta["leaf"] + assert "inline_url" in result.meta["leaf"] + assert "inline_url" not in result.structured_content diff --git a/tests/test_mcp_app.py b/tests/test_mcp_app.py new file mode 100644 index 000000000..000000da8 --- /dev/null +++ b/tests/test_mcp_app.py @@ -0,0 +1,207 @@ +"""The canonical Leaf page delivered through the registered MCP server.""" + +import json +import shutil +import urllib.error +import urllib.request +from urllib.parse import urlsplit + +import anyio +from interact_support import PAGE +from leaf.event_log import append_event, read_events +from leaf.mcp_app import APP_URI +from leaf.mcp_page import ( + PAGE_APP_RESOURCE, + PAGE_FORMAT, + PAGE_RESOURCE_URI, + ProcessPageServer, + page_state, +) +from leaf.mcp_server import make_mcp_server +from leaf.revisioning import activate_source + + +def activate(page_dir, html=PAGE): + (page_dir / "index.html").write_text(html, encoding="utf-8") + active = activate_source(page_dir, read_events(page_dir)) + assert active.error is None + return active.revision + + +def call(server, name, arguments): + async def invoke(): + return await server.call_tool(name, arguments) + + return anyio.run(invoke) + + +def test_process_server_multiplexes_pages_on_one_exact_origin(page_dir, tmp_path): + activate(page_dir) + append_event( + page_dir, + { + "kind": "note", + "author": "claude", + "version": 1, + "revision": 1, + "text": "published", + }, + ) + second = tmp_path / "second" + shutil.copytree(page_dir, second) + pages = ProcessPageServer() + try: + first_url = pages.open(page_dir) + second_url = pages.open(second) + + assert urlsplit(first_url).scheme == "http" + assert urlsplit(first_url).hostname == "localhost" + assert urlsplit(first_url).netloc == urlsplit(second_url).netloc + assert urlsplit(first_url).path != urlsplit(second_url).path + assert urlsplit(first_url).query == "" + assert pages.open(page_dir) == first_url + + with urllib.request.urlopen(first_url) as response: + html = response.read().decode() + assert response.headers.get("Set-Cookie") is None + root = urlsplit(first_url).path.rstrip("/") + assert f'src="{root}/leaf.js"' in html + assert f'href="{root}/theme.css"' in html + + with urllib.request.urlopen( + f"{pages.origin}{root}/runtime/layer-client.js" + ) as response: + runtime = response.read().decode() + assert f'fetch("{root}/api/event"' in runtime + + with urllib.request.urlopen( + f"{pages.origin}{root}/widgets/lf-options.js" + ) as response: + widget = response.read().decode() + assert f'from "{root}/runtime/widget-api.js"' in widget + + with urllib.request.urlopen(f"{pages.origin}{root}/api/state") as response: + state = json.load(response) + assert state["active"]["url"].startswith(f"{root}/revisions/") + assert state["versions"] == [ + {"version": 1, "revision": 1, "url": f"{root}/versions/v1.html"} + ] + with urllib.request.urlopen( + f"{pages.origin}{state['versions'][0]['url']}" + ) as response: + assert f'src="{root}/leaf.js"' in response.read().decode() + + try: + urllib.request.urlopen(f"{pages.origin}/api/state") + except urllib.error.HTTPError as error: + assert error.code == 404 + else: # pragma: no cover - the assertion explains the capability boundary + raise AssertionError("unscoped page route was reachable") + + assert not (page_dir / "service.json").exists() + assert not (second / "service.json").exists() + + (page_dir / "registry.json").unlink() + try: + urllib.request.urlopen(f"{pages.origin}{root}/api/state") + except urllib.error.HTTPError as error: + assert error.code == 500 + assert "vendored registry lacks" in error.read().decode() + else: # pragma: no cover - the assertion explains the fault boundary + raise AssertionError("a route-selection fault dropped the connection") + finally: + pages.close() + + +def test_page_result_keeps_the_capability_private(page_dir): + activate(page_dir) + pages = ProcessPageServer() + try: + summary, private = page_state(str(page_dir), pages) + server = make_mcp_server( + pages, + page_html="page app", + snapshot_html="snapshot app", + ) + result = call(server, "leaf_present", {"page": str(page_dir)}) + finally: + pages.close() + + assert summary == result.structured_content + assert summary["format"] == PAGE_FORMAT + assert summary["mode"] == "page" + assert summary["active"]["revision"] == 1 + assert "inline_url" not in summary + assert "browser_url" not in summary + assert private["inline_url"].startswith("http://localhost:") + assert result.meta["leaf"]["inline_url"] == private["inline_url"] + + +def test_registered_server_prefers_full_page_and_keeps_snapshot_as_fallback(): + pages = ProcessPageServer() + try: + server = make_mcp_server( + pages, + page_html="page app", + snapshot_html="snapshot app", + ) + + async def inspect(): + return await server.list_tools(), await server.list_resources() + + tools, resources = anyio.run(inspect) + finally: + pages.close() + + by_name = {tool.name: tool for tool in tools} + assert set(by_name) == { + "leaf_present", + "leaf_refresh", + "leaf_present_snapshot", + "leaf_snapshot_apply_event", + "leaf_snapshot_refresh", + } + assert by_name["leaf_present"].meta["ui"] == { + "resourceUri": PAGE_RESOURCE_URI, + "visibility": ["model"], + } + assert by_name["leaf_refresh"].meta["ui"]["visibility"] == ["app"] + assert by_name["leaf_present_snapshot"].meta["ui"] == { + "resourceUri": APP_URI, + "visibility": ["model"], + } + assert by_name["leaf_snapshot_apply_event"].meta["ui"]["visibility"] == ["app"] + assert "leaf_delivery_ack" not in by_name + assert "leaf_open_compact_ask" not in by_name + + by_uri = {str(resource.uri): resource for resource in resources} + assert set(by_uri) == {PAGE_RESOURCE_URI, APP_URI} + assert by_uri[PAGE_RESOURCE_URI].meta == { + "ui": { + "csp": { + "connectDomains": [], + "resourceDomains": [], + "frameDomains": [pages.origin], + }, + "prefersBorder": False, + } + } + assert by_uri[APP_URI].meta == { + "ui": { + "csp": {"connectDomains": [], "resourceDomains": []}, + "prefersBorder": False, + } + } + + +def test_shipped_page_app_is_one_self_contained_html_blob(): + page = PAGE_APP_RESOURCE.read_text(encoding="utf-8") + + assert "LEAF_MCP_STYLE" not in page + assert "LEAF_MCP_SCRIPT" not in page + assert "LEAF_MCP_ICON" not in page + assert "@modelcontextprotocol/ext-apps 1.7.5" in page + assert '