js-bridge-mcp
v0.6.6
Published
Generic bridge: exposes MCP tools discovered from a connected page's own JSON tool manifest, dispatched over a cross-origin WebSocket. Ships a hello-world example page under legacy-page/.
Readme
js-bridge-mcp
A generic bridge: MCP tools are discovered at runtime from a connected
page's own JSON tool manifest, and dispatched to that page's window.*
functions over a cross-origin WebSocket. This package ships a worked
"hello world" example of @avo-mcp-tools/mcp-tenant-lib's
Pattern B: AI-enabling an existing static page that this stack
doesn't own or build.
Two independent servers, two independent origins:
js-bridge-mcp's own server — MCP endpoint (/mcp) + WebSocket bridge (/ws) + one static asset (/main.js), on port 8766.legacy-page/hello-world.html— a plain HTML page with an<h1>and a<main>, no build step, no dependency on this stack. Served by any static file server — this example useshttp-server --corson port 8080.
The page and the MCP server never talk to each other directly. The
browser bridges them: a one-line executable JS snippet (generated by the
get_embed_snippet tool) connects straight to js-bridge-mcp's WebSocket,
cross-origin. The primary way to run it is pasting it directly into the
target page's DevTools console — no editing the page's source required,
which is the expected workflow for a developer today. It can also be
wrapped in a <script type="module">...</script> tag and baked into the
page's HTML if preferred. Once connected, the page pushes its own
#mcp-tools JSON manifest over that socket, and the server registers the
tools it describes — see packages/mcp-tenant-lib/BRIDGING.md for how
to write one for a new page.
Run it
Via npx
npx js-bridge-mcpStarts the MCP + WS + main.js server on port 8766. Point an MCP client at
http://localhost:8766/mcp, then serve legacy-page/hello-world.html (or
your own page) with any static file server.
From this repo
npm run build # bundle src/client/main.ts -> dist/client/main.js
npm run start:mcp # MCP + WS + main.js server, port 8766
npm run start:static # serves legacy-page/, port 8080, in a second terminalOpen http://localhost:8080/hello-world.html — it renders as-is, not yet
connected to anything.
Connect an agent
- Point an MCP client at
http://localhost:8766/mcp. - Call
get_embed_snippet. It returns something like:import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>"); - Open
legacy-page/hello-world.htmlin a browser, open DevTools, go to the Console tab, paste that line, and press enter. (Alternatively, wrap it in a<script type="module">...</script>tag and paste it into the page's HTML before</body>— there's a comment marking the spot — then reload.) - Call
insert_title/insert_mainfrom the MCP client — the open tab updates live, no page refresh needed.
The tenant id embedded in the snippet is this MCP session's own tenant, so
repeated get_embed_snippet calls within the same session return the same
tenant id — the page stays connected to whichever session generated the
snippet it's using.
Auto-connect on page load (no DevTools paste)
The manual paste above is the right default for a one-off static page, but a
real app with its own build (Vite/webpack/etc) that wants to stay connected
across every reload doesn't need a human to paste anything, ever. Instead of
a session-minted tenant UUID, the app connects itself on boot using a fixed,
human-readable name. By default this becomes a root connection —
addressed directly, its tools merged into every MCP session automatically,
no join_channel needed. Pass "channel:app-name" instead to join a real,
agent-joinable channel, reachable later via join_channel("<channel-name>").
Either way, zero interaction is needed on the page side after the first
connect.
There's also a packaged skill for this exact recipe:
.agents/skills/js-bridge-mcp-auto-connect-button/SKILL.md — load it before
implementing so you don't reinvent the wiring below from scratch.
Requires window.__mcpTools to already be defined by the page (see
above) — this only handles the connection, not the tool contract.
The connect lifecycle itself (probe, connect, rename, leave-old-channel-on-
switch) is shared infrastructure, served by this package the same way
tool-bus.js is: src/client/connect.js, importable by URL at
<server>/connect.js, exporting one factory:
import { createMcpConnect } from 'http://localhost:8766/connect.js';
const connect = createMcpConnect({ appName: 'myapp' }); // localStorage key + tool-name label; becomes its own ROOT connection named "myapp"
connect.init(); // connects on page load
connect.handleConnectClick(); // wire to a toolbar button
connect.onConnectionStateChange((state, channel, appLabel) => { /* render a status indicator */ });
connect.getConnectionState(); // { state, channel, appLabel } - synchronouscreateMcpConnect also accepts defaultChannel (the raw connect string used
before any human retargets it — defaults to appName, i.e. this app becomes
its own root connection with no channel needed; pass a "channel:app-name"
string instead to join a real, agent-joinable channel by default) and
beforeConnect (an optional async hook run once, before the first
main.js import — for a host page that layers extra tool providers onto
window.__mcpTools first, e.g. via tool-bus.js; see bulletino-1's
mcp-connect.mjs for a worked example).
If the host page's bundler doesn't support top-level await at its configured
build target (common with Vite's default target), connect.js still has to
be reached via a dynamic import() rather than a static one — wrap it in a
small synchronous stub that starts 'disconnected' and swaps in the real
instance once the import resolves, so a UI component that reads
getConnectionState() synchronously at its own module-eval time still
works. See htmlpaint.com's or mindfoo's mcp-connect.js/.ts for the
pattern (native ESM pages with no bundler, like bulletino-1's
mcp-connect.mjs, can just top-level-await it directly).
Root connections vs. "channel:app-name" — joining a shared channel
By default, typing a bare name ("htmlpaint2") in the connect prompt makes
that page its own root connection — addressed directly by name, with its
tools always prefixed htmlpaint2__... and merged into every MCP session
automatically. No join_channel needed; any agent can call
describe_connection("htmlpaint2") to inspect it directly.
Typing "channel:app-name" instead ("bug123:htmlpaint") joins a real,
agent-joinable channel — the part before the colon is the channel name,
the part after sets window.__mcpAppName for this connection specifically.
This is how several different apps deliberately join the same channel
(like inviting several people into one Slack channel) while each keeps its
own readable tool-name prefix instead of colliding on the channel name as
its label: type bug123:htmlpaint in one tab and bug123:bulletino in
another, and both land on channel bug123 with tools prefixed
htmlpaint__... / bulletino__... — see "Multiple tabs on one tenant"
below for how that prefixing works. An agent then reaches them via
join_channel("bug123").
Orphaned channels get cleaned up automatically
Two independent mechanisms, so switching channels (or just closing a tab) doesn't leave a dead tenant sitting around for the 2-hour general idle sweep to eventually notice:
- Explicit switch: when
connect.jsreconnects a tab from channel A to channel B (viahandleConnectClickor a freshinit()), it sends aleave_channelmessage on A's socket before opening the new one on B. The server drops A's tenant immediately if that was its last connection — a no-op if other tabs/apps are still on A. - Tab closed / crashed: the server can't distinguish a genuine tab close
from a brief network drop — both look like the same WebSocket
closeevent. So instead it tracks how long a tenant has had zero connections and disposes it once that exceedsTENANT_EMPTY_TIMEOUT_MS(default 15s, separate from and much shorter thanTENANT_IDLE_TIMEOUT_MS's 2-hour default) — comfortably above the client's ~2s reconnect retry, so a reload or brief blip never trips it, but an actually-closed tab is gone within seconds rather than hours.
Add a thin connector module, e.g. src/mcp-connect.ts, wrapping the shared
factory shown above:
import { createMcpConnect } from 'http://localhost:8766/connect.js';
export const connect = createMcpConnect({ appName: 'myapp' });(If your bundler can't top-level-await a dynamic import at its configured
build target, wrap this in the synchronous-stub pattern described above
instead of a bare re-export — see htmlpaint.com's/mindfoo's mcp-connect
files for the full worked version.)
Wire it into the app's entry point, after window.__mcpTools is set:
import './mcpbridge'; // sets window.__mcpTools
import { connect } from './mcp-connect';
connect.init(); // no dev-mode gate - JSBRIDGE_HOST is always localhostAnd a status button somewhere in the toolbar, bound to
connect.onConnectionStateChange and connect.handleConnectClick:
⚪ myapp -- disconnected, click to connect
🟡 connecting… -- probing/importing
🟢 myapp -- connected as root connection "myapp", click to rename (or "channel:app-name" to join a shared channel)Any MCP client can now reach this page's tools without ever touching
DevTools or calling join_channel first — its tools already appear in
tools/list, prefixed myapp__... (see "Multiple tabs on one tenant"
below for how that prefixing works when more than one connection is
involved).
Multiple entrypoints (js-bridge-mcp vs js-bridge-mcp/client vs /bus vs /connect)
Four ways to consume this package, depending on what you're building:
js-bridge-mcp(npm dependency, server) —npx js-bridge-mcpor programmatic server usage. Unchanged, this is the same package entrypoint as always.js-bridge-mcp/client(npm sub-path, bundler-based host app) —import { connectMcpBridge, defineTool } from 'js-bridge-mcp/client'. The ergonomic all-in-one entrypoint for a normal Vite/TS app: composescreateMcpConnectwith an automatic tool-bus load, replacing the hand-rolledJSBRIDGE_HOST+ dynamic-import()boilerplate a consumer would otherwise write itself.<server>/tool-bus.js(URL import, DevTools-pasteable, zero baggage) —window.__mcpToolBus.registerTool(...). Works standing alone, no other piece of this package required.<server>/connect.js(URL import) — used internally byjs-bridge-mcp/client, and still directly importable for a page with no bundler at all (e.g. a plain<script type="module">app).
A jsDelivr URL to the published npm package's client sub-path (e.g.
https://cdn.jsdelivr.net/npm/js-bridge-mcp@<version>/dist/client/sdk.js)
is a fifth, equivalent way to reach path 2 without installing anything —
useful for a no-bundler host that still wants connectMcpBridge/defineTool's
ergonomics. tool-bus.js/connect.js are deliberately not added as npm
exports sub-paths (no "./bus"/"./connect" in package.json) — their
whole reason for existing is runtime-URL-import (jsDelivr or a local
server fetch), not bundler resolution; don't "fix" this by adding them to
exports later.
Bridge any other project's static HTML to this MCP server
js-bridge-mcp doesn't care what the page is — legacy-page/hello-world.html
is just a worked example. Any static HTML page (in this repo or a totally
unrelated project) can become a tenant of an already-running js-bridge-mcp
server by adding two things to its own source, with zero build-step
dependency on this package. This section is the complete recipe — no need to
go spelunking in other packages' docs.
1. Define window.__mcpTools in the page, before the bridge script runs
A global array of tool definitions, each holding a real function reference (not a string name):
<script>
function highlightRow({ rowId, color }) {
const row = document.getElementById(rowId);
if (!row) throw new Error(`no row with id "${rowId}"`);
row.style.backgroundColor = color ?? 'yellow';
return `highlighted ${rowId}`;
}
window.__mcpTools = [
{
name: 'highlight_row',
description: 'Highlights the table row matching the given id. Call list_rows first if you don\'t know valid ids.',
params: {
rowId: { type: 'string', description: 'The id attribute of the <tr> to highlight' },
color: { type: 'string', description: 'CSS color name, defaults to yellow if omitted', optional: true },
},
example: { rowId: 'row-42', color: 'yellow' },
fn: highlightRow,
},
];
</script>Schema per entry:
name— snake_case, unique on the page. What the MCP-connected agent sees and calls.description— written for the agent, not a human reader: state what it does, preconditions ("call X first"), and side effects. Seeget_embed_snippet's description insrc/tools/hello-tools.tsfor the bar to hit.params— flat object only, values are{ type, description?, optional? }withtypeone of"string"/"number"/"boolean". No nested objects or arrays — the server's JSON→zod converter only supports these three primitives and throws a registration error otherwise. Need structured data? Encode it as a JSON string param andJSON.parseinsidefn.example— a realistic call, useful both as page-source documentation and as something you should actually try once connected.fn— called with a single args object matchingparams(never positional args). Its return value, or a thrownError's message, becomes the tool call's result.fnnever leaves the browser — the bridge strips it before talking to the server, which only ever seesname/description/params/exampleand dispatches calls back bynameagainst its local copy ofwindow.__mcpTools.
Optionally also set window.__mcpAppName (a short string, e.g. "formalin"
or "htmlpaint") before the embed snippet runs. It becomes this
connection's tool-name prefix, always — matters most when the same
get_embed_snippet output gets pasted into more than one browser tab, see
"Multiple tabs on one tenant" below. Falls back to document.title if
omitted.
If the page is an ES module build rather than plain script tags, define
window.__mcpTools in whichever module already has the real functions in
scope — same shape, still a direct function reference, no string lookup.
2. Add the embed snippet, after window.__mcpTools is defined
Get it by calling this server's get_embed_snippet MCP tool (from any MCP
client pointed at http://localhost:8766/mcp); it returns one line like:
import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>");Two ways to run it, both fine:
- Paste into DevTools console on the already-open target page — no source edit at all. This is the default workflow when you (or the agent) have the page open in a browser you control.
- Bake into the page's HTML, wrapped in a module script tag, placed
after the
window.__mcpToolsblock:<script type="module">import("http://localhost:8766/main.js?server=...&tenant=...");</script>
Either way, the bridge reads window.__mcpTools once, synchronously, at
load/(re)connect time — it does not poll. Edit the tool list, then reload the
page (and let the bridge reconnect) before the new tools show up.
Full example: a bare page, wired up end-to-end
This is legacy-page/hello-world.html in full — copy it as a starting point
for any project's own static page. The only things that change per-project
are the functions and tool definitions inside the <script> block; the
embed-snippet line at the bottom is generated fresh per tenant by
get_embed_snippet and pasted in (or run from DevTools instead of baked in).
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello World</title>
</head>
<body>
<h1>Hello, world!</h1>
<main>Waiting for an agent to say something...</main>
<script>
function insertTitle({ title }) {
document.title = title;
document.querySelector('h1').textContent = title;
return `title set to "${title}"`;
}
function insertMain({ main }) {
document.querySelector('main').textContent = main;
return 'main content updated';
}
// window.__mcpTools is the contract the injected bridge script looks
// for: an array of { name, description, params, example, fn } — real
// function references, not string lookups. Must be defined before the
// embed snippet below runs.
window.__mcpTools = [
{
name: 'insert_title',
description: 'Sets the <h1> title shown on this page.',
params: { title: { type: 'string', description: 'New page title' } },
example: { title: 'Welcome, Ada!' },
fn: insertTitle,
},
{
name: 'insert_main',
description: 'Sets the <main> body content shown on this page.',
params: { main: { type: 'string', description: 'New body text' } },
example: { main: 'Here is your daily summary...' },
fn: insertMain,
},
];
</script>
<!-- Paste the snippet from get_embed_snippet here, wrapped in a
<script type="module"> tag — or just run it from DevTools instead. -->
<script type="module">import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>");</script>
</body>
</html>Run this repo's copy of it via npm run start:static (see "Run it" above),
or drop the equivalent markup into any other project's page — nothing here
depends on this package's build tooling.
Optional: run_transient for one-off computations
A page can optionally define one more tool, alongside its regular fixed
ones, that lets an agent write and immediately run a throwaway JS
computation for the current session only — see legacy-page/hello-world.html
for the full worked pilot (runTransient + its manifest entry). The
motivating case: a page exposes some data (e.g. a big list of numbers,
durations, or other values via a get_* tool), and the agent needs an
aggregate over it — sum, average, max/min, a multi-step filter. Doing that
arithmetic itself, in-context, token by token, gets unreliable as the list
grows — it produces a plausible-looking wrong number with no error signal.
Real JS run against the real data is deterministic.
function runTransient({ code, args }) {
const parsedArgs = typeof args === 'string' && args ? JSON.parse(args) : undefined;
// eslint-disable-next-line no-new-func -- deliberate, see hello-world.html for the full rationale
const fn = new Function('args', 'document', 'window', code);
const result = fn(parsedArgs, document, window);
return typeof result === 'string' ? result : JSON.stringify(result);
}
window.__mcpTools.push({
name: 'run_transient',
description: '...(see hello-world.html for the bar to hit — must state clearly this is ' +
'for large/complex computations only, not a replacement for fixed tools, and that "code" ' +
'is a function BODY whose return value becomes the result)',
params: {
code: { type: 'string', description: 'JS function body; receives (args, document, window), return value becomes the result' },
args: { type: 'string', description: 'JSON string passed as `args`; omit if code takes no input', optional: true },
},
fn: runTransient,
});Deliberately not a new registered MCP tool per definition, and
not persisted anywhere (not localStorage, not appended to
window.__mcpTools) — each call compiles code, runs it once, and
discards it:
- The bridge reads
window.__mcpToolsonce at connect and does not poll (see "Common mistakes" above) — a page tool array mutated mid-session wouldn't reach the current session's MCP client without a reconnect anyway, so "register a new tool name per definition" doesn't reliably work today even where the server-side sync supports it in principle. - Persisting agent-authored code across page loads is a materially different, larger risk than running it once in the current tab: it turns into arbitrary code that runs automatically on every future load with no review step. Keep it session-scoped; if a computation turns out to be worth reusing, promote it to a normal hand-authored, reviewed tool in the page's own source instead of auto-persisting what the agent wrote.
This is still full code execution in the page's own origin —
new Function is not meaningfully safer than eval; session-scoping
bounds persistence, not capability. Fine for a page with no
auth/secrets (like the demo page here). A page carrying real session state,
cookies, or API access should treat adding this tool as a deliberate,
visible grant — document it clearly in window.__mcpSummary — not a
default to copy onto every bridged page. The call itself (tool name, the
code string, args, and the result) is an ordinary logged MCP
call/result like any other tool call, so even though the code is
agent-authored, what actually ran is auditable after the fact from the
session transcript.
Common mistakes
- Positional args instead of one args object (
fn({ rowId }), notfn(rowId)). - Defining
window.__mcpToolsafter the embed snippet already ran. - Reusing a
nameacross two entries in the same page's ownwindow.__mcpToolsarray — this is still a real bug (last one wins). Reusing anameacross two different pages/tabs sharing a tenant is fine now — see "Multiple tabs on one tenant" below, each gets an automatic per-connection prefix. - Expecting a plain, direct edit to
window.__mcpToolsitself to take effect without a page reload — that array is still only read fresh when something triggers a re-send (see "Live/late tool registration" below); editing it in place with nothing watching for the change is a no-op until the next reload. - Pasting the embed snippet into the page after your MCP client already
connected: some clients (Claude Code included, observed against
js-bridge-mcp) fetchtools/listonce atinitializeand won't re-poll on the server'stools/list_changednotification mid-session. New tools may need a full MCP client restart to appear, even though the browser tenant is connected and the server registered them correctly. This is the SAME caveat that applies to live/late registration below — the server always registers correctly and always emitstools/list_changed; whether your MCP client notices is a separate, per-client question.
Live/late tool registration
A page's tools no longer have to all exist before the very first connect.
window.__mcpToolBus (see tool-bus.js above) supports registering a tool
at ANY point during an already-connected session — main.js subscribes to
the bus's onChange directly and re-sends the full merged tool list
(window.__mcpTools + the bus's current tools) every time it fires, no
page reload required. The single-tool primitive for this is
registerTool, a DevTools-pasteable sibling to registerProvider:
window.__mcpToolBus.registerTool('save_current_note', () => window.myApp.save(), {
description: 'Saves the currently open note',
});This is the mechanism behind mapping an ad-hoc window.* function (e.g. a
Vue app's exposed instance method) to a tool name with zero source changes
to the host app — paste it in DevTools, and (subject to the MCP-client
caveat immediately above) the tool becomes callable without reconnecting.
Prefer a guided UI over hand-typing registerTool calls? The dashboard
(localhost:8766/) has a tools panel for exactly this — click a
connection's tool count to open it, browse what's registered (tagged host
vs. dynamic), add a new tool by pointing at an existing window.* function
or pasting fresh code, and remove any dynamically-added tool you no longer
need. Host-defined tools can never be removed this way.
Remote registration via MCP tools
The same registration/unregistration mechanism above is also available to
agents, not just humans at the dashboard — three MCP tools (defined in
mcp-tenant-lib, available to any tenant-lib consumer, not js-bridge-mcp-
specific):
register_page_tool_by_path(id?, name, description, path)— points at an existingwindow.*function (e.g.path: "myApp.save"resolveswindow.myApp.save). Use when something the page already does just needs exposing.register_page_tool_by_code(id?, name, description, code)— agent authors a brand-new function body, compiled and run asnew Function('args', 'document', 'window', code)— the same trust model as pasting code into DevTools, but this is standing/persistent, not one-shot. Registers immediately, with no human approval step of any kind — the name/description/code are logged as a sticky toast on this MCP server's dashboard so a human can review what got registered, but that's purely informational and doesn't block anything. A throwing/invalid snippet surfaces as a real tool error, not a silent failure. Good for exploration too: a discovery/inspection function can inform what other tools to register next — this is the closest an agent gets to "do what a human can do at DevTools."unregister_page_tool(id?, toolName)— removes a previously dynamically-added tool by name. Can NEVER remove a host-defined tool (one the page itself defined inwindow.__mcpTools) — errors clearly instead of silently no-op'ing if the name isn't a currently-tracked dynamic registration.
All three accept an optional connection id (from describe_tools'
connections array — omit when only one connection is live, same
convention as identify_connection) and wait for the browser to confirm
success/failure before returning, so a bad path or a failed compile
surfaces as a real tool error, not a silent no-op.
The dashboard's tools panel also lets a human save any dynamic tool (one
with a captured origin) to a .tool.json file via a save button on its
row, select several via checkbox and export them as separate files at
once, and later re-register one or more of them from an "Import tool(s)"
file picker — going through the same register-by-path/register-by-code
routes described above.
- Two tabs of the same page connected to the same tenant get
ordinal-suffixed prefixes (
tab__,tab2__, ...) unlesswindow.__mcpAppName/document.titlediffer between them — calldescribe_toolsto see current prefixes rather than guessing.
Multiple tabs on one tenant
get_embed_snippet returns the same tenant id for the life of an MCP
session, so pasting that same snippet into more than one browser tab — a
different app in each tab, or several tabs of the same app — connects all
of them to the same tenant. This is supported, not just an edge case to
avoid: it's how one MCP session can drive multiple pages at once (e.g.
"read form data from tab A, use it to drive tab B").
- Each WS connection is tracked separately server-side. Every registered MCP
tool name always gets an automatic prefix —
${name}__${tool}— e.g.formalin__submit_form,htmlpaint__clear_canvas, even when it's the only connection present. This is deliberate, not just a collision-avoidance fallback: it's what lets a prompt like "use dbhub-local" resolve directly todbhub_local__queryin a flattools/list, with nojoin_channel/describe_channelround-trip needed first. - The name comes from
window.__mcpAppName(ordocument.titleif unset), sanitized to[a-z0-9_]. Two connections that land on the same name (same app, or both unlabeled) get ordinal-suffixed at registration time: the first to connect keeps the bare name, the next becomesname2, thenname3, etc. — so "use the first htmlpaint tab" maps tohtmlpaint__...tools, and "use the second" maps tohtmlpaint2__.... - Calling
describe_toolsalways returns aconnections[]array (id,label,toolPrefix,summary,tools[]), for 0, 1, or many connections alike — call it whenever you're not sure which prefix routes to which tab. - Calls are routed to exactly one connection's socket — the other tab(s) never see or respond to a call meant for a different one.
- Closing a tab drops its connection and its prefixed tools disappear from
tools/list— any remaining connection keeps its own prefix unchanged (names are stable once assigned, never renumbered by another connection leaving).
Validation checklist before calling it done
- Call
get_embed_snippet, run the snippet against the target page. - Call
tools/list(or just try the new tool by name) — confirm it appears. - Call each new tool with its
exampleargs, confirm the page visibly updates and the result isn't an error. - Open a second tenant (call
get_embed_snippetagain from a fresh MCP session) and confirm the new tools do not appear there — manifests are per-tenant, never global. - Paste the same
get_embed_snippetsnippet into a second browser tab (same tenant, deliberately). Calldescribe_tools— confirm it lists two connections with distinct labels/prefixes (tab/tab2if neither page setwindow.__mcpAppName/title). Call one of the prefixed tools (e.g.tab__insert_title) and confirm only that tab updates, not the other. Close one tab, calldescribe_toolsagain, confirm it now reports a single connection, still prefixed by its own name.
For the fuller version of this recipe (including how to scaffold a brand-new
MCP server package, "Pattern A" vs "Pattern B") see
packages/mcp-tenant-lib/BRIDGING.md and AGENTS.md.
Why this shape
See packages/mcp-tenant-lib/AGENTS.md, "Pattern B" section, for the
general recipe this example follows.
