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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"typecheck": "tsc -p tsconfig.templates.json && tsc -p tsconfig.spec.json && tsc -p tsconfig.jsx.json",
"syntaxcheck": "node --check skills/webmcpify/templates/webmcpify.js",
"test": "node --test \"tests/*.test.mjs\"",
"check": "npm run typecheck && npm run syntaxcheck && npm test"
"check": "npm run typecheck && npm run syntaxcheck && npm test",
"proof:verify": "xvfb-run -a node proof/demo/run.mjs --verify",
"proof:record": "xvfb-run -a node proof/demo/run.mjs --record"
},
"devDependencies": {
"@playwright/test": "^1.54.0",
Expand Down
62 changes: 62 additions & 0 deletions proof/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# WebMCPify proof pack

This is a sanitized, deterministic run against a local release-notes fixture. It
contains no customer data and performs no network mutation. The single candidate,
`set_release_filter`, wraps the same browser-local filter path used by the visible
buttons.

## Reproduce the verification

Requirements: Node 20+, dependencies from `npm ci`, Google Chrome 150+ (earlier
releases do not expose the native `document.modelContext` surface and the run
fails its first assertion), `xvfb-run`, and (for the derivative) ffmpeg. Chrome
is located through Playwright's `chrome` channel; set `CHROME_BIN` to point at a
specific binary. On a desktop session you can skip Xvfb and run
`node proof/demo/run.mjs --verify` directly.

```sh
npm ci
npm run proof:verify
```

The command starts a loopback-only static server, opens system Google Chrome
headed under Xvfb with `--enable-features=WebMCP,WebMCPTesting`, proves that no
tool exists before approval/integration, triggers the bounded integration, then:

1. enumerates `set_release_filter` through native `document.modelContext.getTools()`;
2. parses and compares its stringified schema and checks `readOnlyHint: false`;
3. executes `{ "category": "fix" }` through native `executeTool()`;
4. checks the result string and the visible UI delta; and
5. confirms an invalid enum resolves the runtime's bounded `ERROR:` convention
without changing UI state.

## Reproduce the 63-second uncut recording

```sh
npm run proof:record
ffmpeg -y -i proof/artifacts/webmcpify-proof-source.webm \
-vf scale=854:-2 -c:v libx264 -preset slow -crf 28 -movflags +faststart \
-an proof/artifacts/webmcpify-proof-480p.mp4
sha256sum proof/artifacts/webmcpify-proof-source.webm \
proof/artifacts/webmcpify-proof-480p.mp4 > proof/artifacts/SHA256SUMS
```

The recording is one continuous browser capture. The phase labels and log lines
are advanced by `proof/demo/run.mjs`; the gate is a real click, integration is a
real registration through the vendored runtime, and verification uses Chrome's
native production enumeration/execution surface. Pauses are intentional so the
workflow is legible at normal playback speed.

## Sanitized before/after evidence

- [`manifest.before.json`](manifest.before.json) is the inventory result at the
human gate. The client mutation is still `discovered` and no setup exists.
- [`manifest.after.json`](manifest.after.json) records approval, two bounded setup
paths, and successful native-Chrome verification.
- [`integration.patch`](integration.patch) is the complete conceptual app diff:
one module script plus one tool module; no server endpoint or unrelated UI path.

The manifests contain only fixture paths, loopback URLs, and synthetic release
notes. Video output is reproducible but binary-identical hashes can vary with the
installed Chrome/ffmpeg builds; the checked-in `SHA256SUMS` identifies the review
artifacts in this revision.
2 changes: 2 additions & 0 deletions proof/artifacts/SHA256SUMS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
7e0ef201630a3231abf4b192ec48f17b78d2e9e6cd4f92b34182e49d6d41c7cd proof/artifacts/webmcpify-proof-source.webm
8edf979fc56c9df802c65554591377fab85f35de7baa06829a19d5ea2c3df75e proof/artifacts/webmcpify-proof-480p.mp4
Binary file added proof/artifacts/webmcpify-proof-480p.mp4
Binary file not shown.
Binary file added proof/artifacts/webmcpify-proof-source.webm
Binary file not shown.
41 changes: 41 additions & 0 deletions proof/demo/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const notes = [...document.querySelectorAll('[data-category]')];

export function applyFilter(category) {
let visible = 0;
for (const note of notes) {
const show = category === 'all' || note.dataset.category === category;
note.hidden = !show;
if (show) visible++;
}
document.querySelectorAll('[data-filter]').forEach((button) =>
button.setAttribute('aria-pressed', String(button.dataset.filter === category)));
document.querySelector('#visible-count').textContent = `${visible} release notes visible`;
return visible;
}

document.querySelectorAll('[data-filter]').forEach((button) =>
button.addEventListener('click', () => applyFilter(button.dataset.filter)));

const log = document.querySelector('#log');
window.proof = {
phase(name, title) {
document.querySelectorAll('#phases span').forEach((item) => {
const phases = ['inventory', 'gate', 'integrate', 'verify', 'audit'];
const current = phases.indexOf(name);
const itemIndex = phases.indexOf(item.dataset.phase);
item.className = itemIndex < current ? 'done' : itemIndex === current ? 'active' : '';
});
document.querySelector('#phase-label').textContent = name.toUpperCase();
document.querySelector('#proof-title').textContent = title;
},
line(text) { log.textContent += `${text}\n`; log.scrollTop = log.scrollHeight; },
manifest(show = true) { document.querySelector('#manifest').hidden = !show; },
gate() { document.querySelector('#gate').showModal(); },
check(text) { document.querySelector('#checks').insertAdjacentHTML('beforeend', `<div class="check">✓ ${text}</div>`); },
chrome(version) { document.querySelector('#chrome').textContent = `Chrome ${version} · native document.modelContext`; },
};

document.querySelector('#approve').addEventListener('click', () => {
document.querySelector('#gate').close();
window.dispatchEvent(new CustomEvent('webmcpify:approved'));
});
59 changes: 59 additions & 0 deletions proof/demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WebMCPify proof — safe release-notes fixture</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<header>
<div><b>webmcpify</b> / reproducible proof</div>
<span id="chrome">Chrome · native WebMCP</span>
</header>
<nav id="phases" aria-label="Pipeline">
<span data-phase="inventory">1 INVENTORY</span><span data-phase="gate">2 HUMAN GATE</span>
<span data-phase="integrate">3 INTEGRATE</span><span data-phase="verify">4 VERIFY</span>
<span data-phase="audit">5 AUDIT</span>
</nav>
<main>
<section class="app-card">
<div class="eyebrow">SAFE SAMPLE APP · LOOPBACK ONLY</div>
<h1>Release notes</h1>
<p class="lede">A tiny existing UI with one browser-local action. No auth, customer data, or network writes.</p>
<div class="filters" aria-label="Release filter">
<button data-filter="all" aria-pressed="true">All</button>
<button data-filter="feature" aria-pressed="false">Features</button>
<button data-filter="fix" aria-pressed="false">Fixes</button>
</div>
<div id="notes">
<article data-category="feature"><b>Command palette</b><small>feature</small></article>
<article data-category="fix"><b>Keyboard focus repair</b><small>fix</small></article>
<article data-category="feature"><b>Compact density</b><small>feature</small></article>
<article data-category="fix"><b>Offline retry repair</b><small>fix</small></article>
</div>
<p id="visible-count">4 release notes visible</p>
</section>
<section class="proof-card">
<div class="eyebrow" id="phase-label">WAITING</div>
<h2 id="proof-title">Real workflow, one continuous capture</h2>
<div id="manifest" hidden>
<code>set_release_filter</code><span>imperative · client-only</span>
<p>category: all | feature | fix</p>
<p>wraps existing <code>applyFilter()</code></p>
</div>
<div id="checks"></div>
<pre id="log" aria-live="polite"></pre>
</section>
</main>
<dialog id="gate">
<div class="eyebrow">HUMAN MANIFEST GATE</div>
<h2>Approve one client-only tool?</h2>
<p><code>set_release_filter</code> changes only visible browser state through the existing filter path.</p>
<ul><li>No server mutation</li><li>No personal data</li><li>No destructive/payment action</li></ul>
<button id="approve">Approve manifest</button>
</dialog>
<script type="module" src="./app.js"></script>
<script type="module" src="./webmcp-tools.js"></script>
</body>
</html>
176 changes: 176 additions & 0 deletions proof/demo/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdir, readFile, rename, unlink } from 'node:fs/promises';
import { dirname, extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';

const mode = process.argv.includes('--record') ? 'record' : 'verify';
const root = dirname(fileURLToPath(import.meta.url));
const repo = join(root, '..', '..');
const artifacts = join(repo, 'proof', 'artifacts');
const sourceVideo = join(artifacts, 'webmcpify-proof-source.webm');
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, mode === 'record' ? ms : 20));

const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
const requested = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
const path = requested.startsWith('skills/')
? join(repo, requested)
: join(root, requested);
const allowed = path.startsWith(root) || path.startsWith(join(repo, 'skills'));
if (!allowed || requested.includes('..')) throw new Error('not found');
const body = await readFile(path);
response.writeHead(200, { 'Content-Type': types[extname(path)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' });
response.end(body);
} catch {
response.writeHead(404).end('not found');
}
});

await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
let browser;

try {
await mkdir(artifacts, { recursive: true });
browser = await chromium.launch({
headless: false,
...(process.env.CHROME_BIN ? { executablePath: process.env.CHROME_BIN } : { channel: 'chrome' }),
args: ['--enable-features=WebMCP,WebMCPTesting', '--disable-background-networking'],
});
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
...(mode === 'record' ? { recordVideo: { dir: artifacts, size: { width: 1280, height: 720 } } } : {}),
});
const page = await context.newPage();
const video = page.video();
await page.goto(baseUrl, { waitUntil: 'networkidle' });

const chromeVersion = (await browser.version()).replace(/^Chrome\//, '');
await page.evaluate((version) => window.proof.chrome(version), chromeVersion);
const nativeSurface = await page.evaluate(() => ({
context: typeof document.modelContext,
enumerate: typeof document.modelContext?.getTools,
execute: typeof document.modelContext?.executeTool,
}));
assert.deepEqual(nativeSurface, { context: 'object', enumerate: 'function', execute: 'function' });
assert.equal((await page.evaluate(() => document.modelContext.getTools())).length, 0);

await page.evaluate(() => {
window.proof.phase('inventory', 'Inventory the existing app');
window.proof.manifest();
window.proof.line('$ webmcpify inventory proof/demo');
});
await delay(2500);
for (const line of [
'DETECT static ES modules · loopback · no auth',
'AREA release-notes → existing applyFilter(category)',
'CANDIDATE set_release_filter · client-only mutation',
'SECURITY no server call · no personal data · no destructive action',
]) {
await page.evaluate((text) => window.proof.line(text), line);
await delay(2300);
}

await page.evaluate(() => {
window.proof.phase('gate', 'Stop for manifest approval');
window.proof.line('GATE discovered → approval required');
window.proof.gate();
});
await delay(6500);
await page.click('#approve');
await page.evaluate(() => window.proof.line('APPROVED set_release_filter · no-commit fixture policy'));
await delay(3000);

await page.evaluate(() => {
window.proof.phase('integrate', 'Integrate one bounded tool');
window.proof.line('SETUP vendored zero-dependency runtime');
window.dispatchEvent(new CustomEvent('webmcpify:integrate'));
});
await delay(2600);
for (const line of [
'WIRE execute() → existing applyFilter(category)',
'DIFF one module script + one tool module',
'BUILD JavaScript syntax check passed',
]) {
await page.evaluate((text) => window.proof.line(text), line);
await delay(2400);
}

await page.waitForFunction(async () => (await document.modelContext.getTools()).some((tool) => tool.name === 'set_release_filter'));
await page.evaluate(() => window.proof.phase('verify', 'Verify through native Chrome'));
const tool = await page.evaluate(async () => (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter'));
assert(tool);
assert.equal(tool.annotations.readOnlyHint, false);
assert.equal(tool.annotations.untrustedContentHint, false);
assert.deepEqual(JSON.parse(tool.inputSchema), {
type: 'object',
properties: { category: { type: 'string', enum: ['all', 'feature', 'fix'] } },
required: ['category'],
additionalProperties: false,
});
await page.evaluate(() => { window.proof.check('tool enumerated'); window.proof.line('GETTOOLS set_release_filter found'); });
await delay(2800);
await page.evaluate(() => { window.proof.check('schema + annotations match'); window.proof.line('SCHEMA parsed stringified JSON Schema · exact match'); });
await delay(2800);

const before = await page.locator('article:visible').count();
const result = await page.evaluate(async () => {
const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter');
return document.modelContext.executeTool(registered, JSON.stringify({ category: 'fix' }));
});
const after = await page.locator('article:visible').count();
assert.equal(before, 4);
assert.equal(after, 2);
assert.match(result, /2 release notes visible/);
await page.evaluate(() => { window.proof.check('valid call changed visible UI: 4 → 2'); window.proof.line('EXECUTE category=fix → 2 release notes visible'); });
await delay(3300);

const invalidResult = await page.evaluate(async () => {
const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter');
return document.modelContext.executeTool(registered, JSON.stringify({ category: 'private' }));
});
assert.match(invalidResult, /^ERROR:/);
assert.equal(await page.locator('article:visible').count(), 2);
await page.evaluate(() => { window.proof.check('invalid enum returned bounded error; UI unchanged'); window.proof.line('INVALID category=private → ERROR (no UI side effect)'); });
await delay(3000);
await page.evaluate(async () => {
const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter');
return document.modelContext.executeTool(registered, JSON.stringify({ category: 'all' }));
});
assert.equal(await page.locator('article:visible').count(), 4);
await page.evaluate(() => { window.proof.check('cleanup restored all notes'); window.proof.line('CLEANUP category=all → fixture restored'); });
await delay(3000);

await page.evaluate(() => {
window.proof.phase('audit', 'Audit and package the evidence');
window.proof.line('AUDIT every diff hunk maps to the approved tool/setup');
});
await delay(2500);
for (const text of [
'Sanitized manifest: discovered → approved → verified',
'No server mutations or production side effects',
'Source recording + 480p derivative + SHA-256 sums',
]) {
await page.evaluate((value) => window.proof.check(value), text);
await delay(2400);
}
await page.evaluate(() => window.proof.line('DONE native Chrome verification green · 0 heal attempts'));
await delay(7000);

await context.close();
if (mode === 'record') {
const generated = await video.path();
await unlink(sourceVideo).catch((error) => { if (error.code !== 'ENOENT') throw error; });
await rename(generated, sourceVideo);
console.log(`recorded ${sourceVideo}`);
}
console.log(`proof verified in Chrome ${chromeVersion}: native getTools/executeTool, schema, annotations, UI delta, bounded invalid input, cleanup`);
} finally {
await browser?.close().catch(() => {});
await new Promise((resolve) => server.close(resolve));
}
32 changes: 32 additions & 0 deletions proof/demo/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
:root { color-scheme: dark; --bg:#0d1110; --panel:#151b19; --line:#34413c; --ink:#f2f4ef; --muted:#9ba8a2; --green:#77e3a2; --amber:#ffc96b; }
* { box-sizing:border-box; }
[hidden] { display:none !important; }
body { margin:0; min-height:100vh; background:var(--bg); color:var(--ink); font:18px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
header { height:64px; padding:0 36px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
header span { color:var(--green); font-size:14px; }
nav { height:58px; padding:0 36px; display:flex; gap:10px; align-items:center; border-bottom:1px solid var(--line); }
nav span { padding:7px 12px; color:var(--muted); border:1px solid transparent; font-size:13px; }
nav span.active { color:var(--bg); background:var(--amber); border-color:var(--amber); }
nav span.done { color:var(--green); border-color:var(--green); }
main { display:grid; grid-template-columns:1fr 1fr; gap:24px; padding:28px 36px; }
section { min-height:560px; padding:28px; border:1px solid var(--line); background:var(--panel); }
.eyebrow { color:var(--amber); font-size:12px; letter-spacing:.12em; font-weight:800; }
h1 { margin:.3em 0 .15em; font:700 42px/1.1 system-ui,sans-serif; }
h2 { margin:.35em 0 .8em; font:700 27px/1.2 system-ui,sans-serif; }
.lede { color:var(--muted); font:16px/1.5 system-ui,sans-serif; }
.filters { display:flex; gap:8px; margin:25px 0 18px; }
button { padding:10px 15px; color:var(--ink); background:#202925; border:1px solid var(--line); font:700 14px inherit; cursor:pointer; }
button[aria-pressed="true"], #approve { color:var(--bg); background:var(--green); border-color:var(--green); }
article { display:flex; justify-content:space-between; padding:15px 2px; border-bottom:1px solid var(--line); font-family:system-ui,sans-serif; }
article small { color:var(--muted); }
#visible-count { color:var(--green); font-size:14px; }
#manifest { padding:18px; border:1px solid var(--amber); background:#101412; }
#manifest span { float:right; color:var(--muted); font-size:13px; }
#manifest p { margin:.65em 0 0; color:var(--muted); font-size:14px; }
#checks { margin-top:20px; }
.check { margin:8px 0; color:var(--green); }
pre { min-height:260px; margin-top:20px; padding:16px; overflow:hidden; white-space:pre-wrap; color:#cdd7d2; background:#090c0b; border:1px solid #27302d; font-size:13px; line-height:1.55; }
dialog { width:620px; color:var(--ink); background:#171e1b; border:2px solid var(--amber); box-shadow:0 24px 90px #000; padding:30px; }
dialog::backdrop { background:rgba(0,0,0,.72); }
dialog p, dialog li { font-family:system-ui,sans-serif; color:#c7d0cb; }
dialog #approve { margin-top:12px; }
Loading
Loading