secret-menu 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,84 +1,54 @@
1
1
  # 🗝️ Secret Menu
2
2
 
3
- **Secret Menu connects you and your agent to WebMCP tools across sites.**
3
+ **See the WebMCP tools a website offers, and let your agent use them as you.**
4
4
 
5
- Some websites now publish tools for AI agents through WebMCP (`document.modelContext`): "search the catalog", "show a variant", "add to cart". Secret Menu finds those tools in your browser tabs, explains in plain language what they let you do, and lets an agent call them as you, through a local MCP server and a Chrome extension.
5
+ [![npm](https://img.shields.io/npm/v/secret-menu)](https://www.npmjs.com/package/secret-menu)
6
+ [![CI](https://github.com/israelvicars/secret-menu/actions/workflows/ci.yml/badge.svg)](https://github.com/israelvicars/secret-menu/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
8
 
7
9
  <p align="center">
8
10
  <picture>
9
- <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/badge-dark.png">
10
- <img src="docs/screenshots/badge-light.png" width="460" alt="The in-page badge: What more can I do here? 14 WebMCP tools">
11
+ <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/popup-dark.png">
12
+ <img src="docs/screenshots/popup-light.png" width="404" alt="Secret Menu's toolbar popup on a test shop: 14 WebMCP tools grouped into six abilities, each tagged safe, needs sign-in, changes data or spends money.">
11
13
  </picture>
12
14
  </p>
13
15
 
14
- <table>
15
- <tr>
16
- <td width="50%"><picture>
17
- <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/popup-dark.png">
18
- <img src="docs/screenshots/popup-light.png" alt="The toolbar popup: the page's tools grouped into abilities labelled safe, needs sign-in, changes data and spends money">
19
- </picture></td>
20
- <td width="50%"><picture>
21
- <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/popup-expanded-dark.png">
22
- <img src="docs/screenshots/popup-expanded-light.png" alt="An expanded ability: its tool names, where each label came from, and a ready-to-copy agent prompt">
23
- </picture></td>
24
- </tr>
25
- </table>
26
-
27
- <sub>The badge and popup on Secret Menu's built-in test page (<code>secret-menu serve</code>), a fake shop with 14 tools. Nothing on it is real.</sub>
28
-
29
- > [!IMPORTANT]
30
- > **WebMCP is still experimental, and so is this project.**
31
- >
32
- > - **Stock Chrome barely shows these tools yet.** Without a flag, Chrome only exposes WebMCP tools on sites taking part in the WebMCP origin trial (Chrome 149 to 162, ending March 30, 2027). There is no ship date for WebMCP in stable Chrome yet.
33
- > - **Most testing ran with the WebMCP flag on.** `secret-menu launch` starts its own Chrome with `--enable-features=WebMCP`. In one check without the flag, the test browser (Chrome for Testing 156) still showed tools on three sample stores. Stock Chrome without the flag hasn't been checked.
34
- > - **macOS and Linux only.** `npx secret-menu setup` works with Google Chrome on macOS and Linux. The dedicated test Chrome (`secret-menu launch`, for development) needs an Apple Silicon Mac. Windows isn't supported yet.
35
- > - **Safety controls are opt-in.** By default calls run, and your agent's own approval prompt is the check. See [Safety model](#safety-model).
36
-
37
- ## What it does
16
+ <p align="center"><sub>The popup on Secret Menu's built-in test shop (<code>./bin/secret-menu serve</code> in a clone, then open <code>/explain.html</code>). The shop and its 14 tools are fake.</sub></p>
38
17
 
39
- - **Explain a page.** `secret-menu explain` lists the tools a page registers and groups them into a few abilities: *safe*, *changes the view*, *needs sign-in*, *changes data*, *spends money*. Every ability names the real tool names, and every label says whether it came **from a site hint** (the page's own annotations) or is **guessed** (Secret Menu's rules over names, descriptions and inputs). No model writes any of it.
40
- - **Call tools from the command line.** List, inspect and call a page's tools in a dedicated Chrome session, and watch tools appear and disappear as pages change.
41
- - **Give an agent the tools.** `secret-menu mcp` is a stdio MCP server, so any MCP client (Codex, Claude, Cursor and others) can find sites, open them, read their tools and call them as the signed-in user.
42
- - **See them while browsing.** The Chrome extension shows a small badge on pages that have tools and a toolbar popup listing what the page can do.
43
- - **Learn from use.** Each site you explain is saved to a local index on your machine. There is no crawler and no server.
18
+ > [!NOTE]
19
+ > WebMCP is an experimental protocol. Turn on Chrome's WebMCP flag (or use a site in Google's trial). macOS or Linux. [Details](#requirements)
44
20
 
45
- ## Requirements
21
+ **Why**
46
22
 
47
- - Node.js 22 or newer
48
- - Google Chrome on macOS or Linux (Windows isn't supported yet)
49
- - The Secret Menu extension in Chrome. A Chrome Web Store listing is coming; until then, load it unpacked (see below).
50
- - Sites that publish WebMCP tools. In stable Chrome that's only sites in the WebMCP origin trial, unless WebMCP is turned on (see the note above).
23
+ - **Building a site with WebMCP?** See your tools the way an agent will, grouped and labelled, with each label marked as your hint or our guess.
24
+ - **Using an agent?** A local MCP server lets Claude, Cursor or Codex use the WebMCP tools on sites you have open.
25
+ - **Local only.** No servers, accounts or analytics.
51
26
 
52
- ## Install
27
+ ## Try it in 5 minutes
53
28
 
54
- There are two parts. The **extension** shows you a page's tools while you browse. The **`secret-menu` package on npm** connects the extension to your computer, so an agent can use those tools. The extension works on its own; the package needs the extension.
29
+ 1. In Chrome, open `chrome://flags/#enable-webmcp-testing`, set it to **Enabled**, and relaunch.
30
+ 2. Install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/ldijmoknbnamjlegbeplefpmhhfmjakh). **In review: this link goes live once Chrome Web Store approves it.** Until then, build it and load it unpacked:
55
31
 
56
- ### 1. The extension
32
+ ```sh
33
+ git clone https://github.com/israelvicars/secret-menu.git
34
+ cd secret-menu
35
+ npm --prefix ext install && npm run ext:build
36
+ ```
57
37
 
58
- The Chrome Web Store listing is coming. Until then, build it from this repo and load it unpacked:
38
+ Open `chrome://extensions`, turn on **Developer mode**, choose **Load unpacked** and pick `ext/.output/chrome-mv3`.
39
+ 3. In the same `secret-menu` folder, run `./bin/secret-menu serve` and open `http://127.0.0.1:8765/explain.html`. (Port busy? Add `--port 8766`.)
40
+ <!-- Back to `npx secret-menu serve` once secret-menu@0.1.1 is on npm (0.1.0 has no serve). -->
41
+ 4. Click the 🗝️ in your toolbar (pin it from the puzzle-piece menu), or the badge on the page. You should see 14 tools in six abilities.
59
42
 
60
- ```sh
61
- git clone https://github.com/israelvicars/secret-menu.git
62
- cd secret-menu
63
- npm --prefix ext install
64
- npm run ext:build # written to ext/.output/chrome-mv3
65
- ```
43
+ The extension works on its own. To let an agent use the tools too, connect it:
66
44
 
67
- Open `chrome://extensions`, turn on Developer mode, choose **Load unpacked** and pick `ext/.output/chrome-mv3`. The build keeps the extension's pinned ID, so setup below finds it without extra flags.
68
-
69
- ### 2. Set up (once)
45
+ ## Connect your agent
70
46
 
71
47
  ```sh
72
48
  npx secret-menu setup
73
49
  ```
74
50
 
75
- This installs Chrome's native messaging host for Secret Menu (`com.secretmenu.bridge`) and a small launcher under `~/.secret-menu`. It's safe to run again; it says what it wrote or left unchanged. If your extension has a different ID, pass it: `npx secret-menu setup --extension-id <32-letter id>` (or set `SECRET_MENU_EXTENSION_ID`).
76
-
77
- Then reload the extension (or restart Chrome).
78
-
79
- ### 3. Connect your agent
80
-
81
- Add Secret Menu as an MCP server in your agent (for example, the MCP settings in Claude Desktop or Cursor):
51
+ This installs a small helper that connects the extension to your computer (technically a Chrome native messaging host, `com.secretmenu.bridge`). Then add this to your agent's MCP settings (for example Claude Desktop or Cursor):
82
52
 
83
53
  ```json
84
54
  {
@@ -88,154 +58,111 @@ Add Secret Menu as an MCP server in your agent (for example, the MCP settings in
88
58
  }
89
59
  ```
90
60
 
91
- Your agent gets tools to list your tabs, read a page's WebMCP tools, explain them in plain words, call them, find sites in the local index, open a site, and run saved recipes (see [MCP tools](#mcp-tools)).
92
-
93
- ### 4. Check it
94
-
95
- ```sh
96
- npx secret-menu doctor
97
- ```
98
-
99
- Each line is `ok` or `FAIL` with a fix: Node version, the native host manifest, the launcher, the extension ID, and whether Chrome's bridge is reachable. It exits non-zero when something fails.
61
+ Reload the extension (or restart Chrome), then check it with `npx secret-menu doctor`. Each line says `ok` or `FAIL`, with a fix.
100
62
 
101
- ### What you get from each part
63
+ Setup is safe to run again. It finds your Chrome-family browsers and installs into each; pass `--browser chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary` (macOS) or `chromium` to pick one, and `--extension-id <id>` if your extension has a different ID. See [docs/CLI.md](docs/CLI.md#setup).
102
64
 
103
- - **Extension only:** an in-page badge on pages that have WebMCP tools, and a toolbar popup that explains them as abilities (safe, changes the view, needs sign-in, changes data, spends money), with the real tool names and a ready-to-copy agent prompt. The popup only reads tools; it never calls them. Before setup, Discover and Hand off show a "Connect your agent" step.
104
- - **Extension plus the package:** your agent sees the same tools and labels you do, and its calls run as you on sites you're signed in to. The Hand off page names the page's real tools in a prompt for your agent, and Discover lists the sites in your local index.
105
- - **The MCP server alone** starts without a browser, but then only `find_sites` and `list_recipes` work, from data already on your machine.
65
+ ## What it does
106
66
 
107
- ### Developing from a clone: the dedicated test Chrome
67
+ - **Explains a page's tools.** It groups them into a few abilities: *safe*, *changes the view*, *needs sign-in*, *changes data*, *spends money*. Each ability lists the real tool names, and each label says whether it came **from a site hint** (the page's own annotations) or is **guessed** (Secret Menu's rules). No model writes any of it, and the popup never calls a tool.
68
+ - **Shows you while you browse.** A small badge appears only on pages that have tools; the toolbar popup lists what the page can do, with a prompt to copy to an agent.
69
+ - **Gives your agent the same tools.** `secret-menu mcp` is a stdio MCP server. Your agent can open a site, read its tools and call them as you, in your signed-in Chrome.
70
+ - **Remembers sites.** Each site you or your agent explains is saved to a local index (Discover in the extension, `find_sites` for agents). There is no crawler.
108
71
 
109
- From a source checkout you can also run Secret Menu in its own Chrome for Testing (Apple Silicon Mac only), with the WebMCP flag on and its own profile, never your everyday Chrome. `launch` and `serve` only work from a clone, not from the npm package.
72
+ <p align="center">
73
+ <picture>
74
+ <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/popup-expanded-dark.png">
75
+ <img src="docs/screenshots/popup-expanded-light.png" width="404" alt="An expanded ability showing its real tool names, whether each label is a site hint or guessed, and a prompt to copy to an agent.">
76
+ </picture>
77
+ </p>
110
78
 
111
- ```sh
112
- npx @puppeteer/browsers install chrome@156 --path "$HOME/.secret-menu/browsers"
113
- ./bin/secret-menu launch # opens the Secret Menu Chrome with the extension loaded
114
- ./bin/secret-menu stop
115
- ```
79
+ <p align="center"><sub>An expanded ability: the real tool names, where each label came from, and a prompt to copy.</sub></p>
116
80
 
117
- ### Try it on the test page
81
+ <p align="center">
82
+ <picture>
83
+ <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/badge-dark.png">
84
+ <img src="docs/screenshots/badge-light.png" width="308" alt="A small badge on the page: What more can I do here? 14 WebMCP tools.">
85
+ </picture>
86
+ </p>
118
87
 
119
- ```sh
120
- ./bin/secret-menu serve # test page on http://127.0.0.1:8765/
121
- ```
88
+ <p align="center"><sub>The in-page badge. It appears only on pages that have tools.</sub></p>
122
89
 
123
- In a second terminal:
90
+ <p align="center">
91
+ <img src="docs/screenshots/agent-demo.gif" width="760" alt="An agent opens the test shop through Secret Menu, reads its tools and looks up a product.">
92
+ </p>
124
93
 
125
- ```sh
126
- ./bin/secret-menu launch http://127.0.0.1:8765/
127
- ./bin/secret-menu tabs # tab ids
128
- ./bin/secret-menu tools # tools on the active tab
129
- ./bin/secret-menu explain --tab <id> # what those tools let you do
130
- ./bin/secret-menu call get_status # call one
131
- ./bin/secret-menu stop
132
- ```
94
+ <p align="center"><sub>The MCP calls an agent makes on the test shop: <code>open_site</code>, <code>explain_page</code>, then <code>call_page_tool</code>. Results shortened; the data is fake.</sub></p>
133
95
 
134
96
  ## MCP tools
135
97
 
136
- | Tool | Needs the Secret Menu Chrome | What it does |
98
+ | Tool | Needs Chrome open | What it does |
137
99
  | --- | --- | --- |
138
- | `find_sites` | No | Search the local index of sites Secret Menu has already explained, for ones that can do a task. |
139
- | `list_recipes` | No | List ready-made multi-site recipes, plus sequences learned from earlier successful sessions. |
100
+ | `find_sites` | No | Search the local index of sites Secret Menu has explained, for ones that can do a task. |
101
+ | `list_recipes` | No | List ready-made multi-site recipes, plus sequences learned from earlier sessions. |
140
102
  | `run_recipe` | Yes | Run a read-only recipe end to end, for example `compare-shopify` to compare products across Shopify stores. |
141
103
  | `open_site` | Yes | Open a site (or reuse a tab Secret Menu opened), wait for its WebMCP tools, and explain them. |
142
- | `list_tabs` | Yes | List open web tabs in the Secret Menu Chrome. |
104
+ | `list_tabs` | Yes | List open web tabs. |
143
105
  | `list_page_tools` | Yes | List a tab's raw WebMCP tools: name, description, input schema, annotations. |
144
106
  | `explain_page` | Yes | Explain a tab's tools as labelled abilities, with the exact tool names. |
145
107
  | `call_page_tool` | Yes | Call one WebMCP tool on a tab, as the signed-in user. |
146
108
 
147
- **Secret Menu follows each site's own directions.** `call_page_tool` returns the page tool's output untouched, including any next steps the site suggests. For example, Shopify search results end with "Next steps: call add_to_cart …". Secret Menu doesn't strip or rewrite site text. The only change is a 20,000-character cap, marked `truncated: true`. (`run_recipe` is the exception: it returns Secret Menu's own normalized rows.)
109
+ `call_page_tool` returns the site's own output untouched (capped at 20,000 characters), including any next steps the site suggests. For a read-only server, run `secret-menu mcp --read-only` or `--allow-tools a,b`; see [docs/SAFETY.md](docs/SAFETY.md#read-only-mode).
148
110
 
149
- **Read-only mode:** `secret-menu mcp --read-only`, or `--allow-tools search_catalog,get_product`, makes `call_page_tool` refuse tools whose names or labels look like they change a cart, order, checkout or account (and, with `--allow-tools`, any tool not on the list). It's a filter based on names and labels, not a guarantee. In that mode `call_page_tool` is advertised as read-only, so agents don't prompt for every call. `run_recipe` is read-only by design and isn't limited by `--allow-tools`.
111
+ ## Requirements
150
112
 
151
- Try it with Codex (after setup, with Chrome open):
113
+ - Node.js 22 or newer.
114
+ - Google Chrome (or Chrome Beta, Dev or Canary on macOS, or Chromium) on **macOS or Linux**. Windows isn't supported yet.
115
+ - **WebMCP turned on.** Stable Chrome needs `chrome://flags/#enable-webmcp-testing`. Without it, Chrome only exposes WebMCP tools on sites in Google's WebMCP origin trial (Chrome 149 to 162, ending March 30, 2027). WebMCP has no ship date in stable Chrome yet, and most of our testing ran with the flag on.
116
+ - The Secret Menu extension. [Chrome Web Store](https://chromewebstore.google.com/detail/ldijmoknbnamjlegbeplefpmhhfmjakh) (**In review: this link goes live once Chrome Web Store approves it.**). Until then, load it unpacked (see [Try it](#try-it-in-5-minutes)).
152
117
 
153
- ```sh
154
- codex exec --ephemeral --skip-git-repo-check -s read-only \
155
- -c 'mcp_servers.secret-menu.command="npx"' \
156
- -c 'mcp_servers.secret-menu.args=["-y","secret-menu","mcp","--allow-tools","search_catalog,get_product"]' \
157
- "Compare women's sneakers under \$120 across Allbirds, Glossier and Away. Use the secret-menu tools."
158
- ```
118
+ If the popup can't find WebMCP on a page, it tells you and links to the flag:
119
+
120
+ <p align="center">
121
+ <picture>
122
+ <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/flag-off-dark.png">
123
+ <img src="docs/screenshots/flag-off-light.png" width="404" alt="The popup when WebMCP is probably off in this Chrome, with a button to open the flag.">
124
+ </picture>
125
+ </p>
126
+
127
+ ### Known limits
128
+
129
+ - WebMCP and Chrome's API names are experimental and may change.
130
+ - Labels come from rules, not understanding. Sites that leave out their read-only hints get guessed labels, and sign-in needs aren't always detected.
131
+ - Only top frames and HTTP(S) tabs are inspected.
132
+ - Safety controls are opt-in (see below).
133
+
134
+ See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for the full list.
159
135
 
160
- ## The Chrome extension
136
+ ## Safety and privacy
161
137
 
162
- The extension lives in `ext/` (WXT, React, Tailwind and shadcn/ui on Base UI). Its Chrome Web Store listing is coming; until then, load the local build unpacked. After changing it, run `npm run ext:build` and reload it (or `secret-menu stop` and `secret-menu launch` in the test Chrome).
138
+ - **Your agent's approval prompt is the check by default.** Calls run as you, on sites you're signed in to.
139
+ - **`secret-menu safety on`** adds an approval step for tools the page doesn't mark read-only, plus a hash-chained audit log. Per-site allowlists and a read-only MCP mode are also available.
140
+ - **No servers, accounts, analytics or telemetry.** What Secret Menu saves stays on your computer, and page tools only reach an agent you connect yourself.
163
141
 
164
- - **Badge:** a small in-page badge appears only on pages that have tools. Click it to open the popup, or press × to dismiss it.
165
- - **Popup:** each ability shows a title and a label. Expand a row to see the full title, its tools, notes and a ready-to-copy agent prompt. **Re-check** reads the tools again. The popup is read-only and never calls page tools.
166
- - **Before setup:** the extension works on its own; Discover and Hand off show a "Connect your agent" step with `npx secret-menu setup`.
167
- - **Hand off:** builds an agent prompt from the page's real tools, grouped by what they let you do. Changing data and spending money stay off unless you switch them on. "Run in agent" isn't built yet, so copy the prompt into an agent that has Secret Menu connected.
168
- - **Discover:** lists the sites in your local index.
169
- - **Theme:** one button cycles Light, Dark and System.
142
+ Details and limits: [docs/SAFETY.md](docs/SAFETY.md) and [PRIVACY.md](PRIVACY.md).
170
143
 
171
144
  ## Command line
172
145
 
173
146
  | Command | What it does |
174
147
  | --- | --- |
175
- | `launch [--headless] [url...]`, `stop [--force] [--timeout SECONDS] [--kill]`, `status`, `tabs` | Start, stop and inspect the Secret Menu Chrome |
176
- | `open <url> [--new]`, `reload`, `close [--tab N \| --mine]` | Drive tabs |
177
- | `tools [--tab N] [--json] [--wait-tools]` | List a page's tools, with the same five labels as the popup |
178
- | `form <tool>`, `call <tool> [--arg k=v] [--args JSON] [--yes]` | Inspect and call a tool |
179
- | `explain [--tab N] [--json] [--seed-allowlist]` | Explain a page's tools as abilities |
180
- | `watch [--tab N] [--for SECONDS]` | Stream tools appearing and disappearing |
181
- | `mcp [--read-only] [--allow-tools a,b]` | Run the stdio MCP server |
182
- | `safety [on\|off]` | Show or change the safety setting |
183
- | `allow`, `disallow`, `sites` | Edit the per-site allowlist |
184
- | `approvals`, `approve <id>`, `deny <id>` | Handle pending MCP approvals (when safety is on) |
185
- | `log [--n 20] [--redact]`, `verify` | Read the audit log and check its hash chain |
186
- | `setup [--extension-id ID]`, `doctor`, `uninstall` | Install, check and remove the native messaging host for your Chrome |
187
- | `serve` | Serve the test fixture on `http://127.0.0.1:8765/` (from a clone) |
188
-
189
- Run `npx secret-menu --help` (or `./bin/secret-menu --help` in a clone) for the full list. Use `--wait-tools` (with `--settle MS` and `--timeout MS`) on pages that register tools a few seconds after loading.
190
-
191
- Secret Menu only closes or navigates tabs it opened itself: tabs from `open --new`, `explain <url>`, MCP `open_site`, and the tabs `launch <url...>` opens. `close` and `open` refuse other tabs unless you add `--force` (`reload` reloads whichever tab you point it at). `stop` refuses while the Secret Menu Chrome has tabs you opened. It lists them so you can decide, and `stop --force` quits anyway.
192
-
193
- `stop` sends SIGTERM and waits up to `--timeout` seconds (default 8). It only force-kills Chrome if you pass `--kill`; otherwise a Chrome that is still running is reported and `stop` exits with code 4.
194
-
195
- ## Safety model
196
-
197
- Secret Menu builds roads; it doesn't police them. The checks below run in the command line (`secret-menu call`) and in the MCP server's `call_page_tool`. The native messaging host and the extension pass on what those clients send and don't check policy themselves.
198
-
199
- - **Off by default.** Calls run, and your agent's own approval UI decides what needs a yes.
200
- - **`secret-menu safety on`** turns on the guard and the audit log.
201
- - **Guard:** tools the page doesn't mark read-only need your approval: a prompt or `--yes` on the CLI, or a one-time `secret-menu approve <id>` for MCP, then a retry within ten minutes. The read-only mark (`readOnlyHint`) comes from the page itself, so a tool the page marks read-only runs without a prompt.
202
- - **Audit:** the native host writes every request the CLI or MCP server sends through the bridge to `audit.jsonl` when it finishes, as a hash chain that `secret-menu verify` checks. Calls the CLI or MCP server refuse are logged too. The popup and Discover read inside the extension and aren't logged.
203
- - **Per-site allowlist** (`~/.secret-menu/sites.json`, edited with `secret-menu allow`) applies to `secret-menu call`, MCP `call_page_tool` and `explain --probe` whether or not safety is on, because adding an entry is itself an opt-in. Listed tools run without a prompt. `--strict` refuses every other tool on that site. MCP read-only mode still refuses an allowlisted tool that looks like it changes data. The allowlist trusts your judgement, not the page.
204
- - **Limits you should know about:**
205
- - `run_recipe` skips the guard and the allowlist. It only ever calls `search_catalog`, so it runs on a `--strict` site that doesn't list that tool, and with the guard on even if the page doesn't mark it read-only. It's still audited.
206
- - Any program running as you can talk to the bridge socket (`~/.secret-menu/run/bridge.sock`, readable only by you) directly and skip the guard, the allowlist and read-only mode. The audit log still records those calls when it's on, along with the approval label the program reports.
207
- - A Chrome started with a remote debugging port (the screenshot harness in [TESTING.md](TESTING.md) uses one) can be driven without any of these checks. `secret-menu launch` doesn't open one.
208
- - Approvals that expire unused aren't written to the audit log.
209
- - The audit anchor (`audit.jsonl.head`) sits next to the log, so someone who can edit both can still hide a truncated log.
210
- - Allowlisted tools are trusted by name. If a site later changes what a tool does, the allowlist still lets it run.
211
- - The local data folder holds your signed-in Chrome profile, and tool arguments appear in the audit log. Use `secret-menu log --redact` before sharing log lines.
212
-
213
- ## Configuration
214
-
215
- - Data lives in `~/.secret-menu`. Change it with `SECRET_MENU_HOME`.
216
- - `SECRET_MENU_CHROME` overrides Chrome discovery.
217
- - `SECRET_MENU_MCP_READ_ONLY=1` and `SECRET_MENU_MCP_ALLOW_TOOLS=a,b` set MCP read-only mode.
218
-
219
- ## Known issues and limits
148
+ | `setup`, `doctor`, `uninstall` | Install, check and remove the helper for your Chrome-family browsers |
149
+ | `mcp [--read-only] [--allow-tools a,b]` | Run the stdio MCP server for your agent |
150
+ | `serve [--port N]` | Serve the test pages on `http://127.0.0.1:8765/` (the test shop is `/explain.html`) |
151
+ | `explain`, `tools`, `call <tool>` | Explain, list and call a page's tools |
152
+ | `safety [on\|off]`, `allow`, `log` | Safety setting, per-site allowlist and audit log |
153
+ | `launch`, `stop` | A separate test Chrome, for development from a clone |
220
154
 
221
- - WebMCP and Chrome's API names are experimental and may change.
222
- - Closing the last window of the Secret Menu Chrome stops the bridge. Only `secret-menu launch` recovers, and `watch` goes silent without reconnecting.
223
- - Explain labels are rules, not understanding. Sites that leave their read-only hints off get guessed labels, and sign-in needs aren't always detected.
224
- - Store search is the site's own and can be fuzzy. Glossier, for example, answers "bag" with pet-waste bags. The `compare-shopify` recipe uses simple keyword rules.
225
- - Only top frames and HTTP(S) tabs are inspected. Results are cut at 20,000 characters.
226
- - Tabs opened before Secret Menu started tracking tabs are treated as yours, not Secret Menu's.
227
-
228
- See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for the full list and [TESTING.md](TESTING.md) for tests and UI test hooks.
155
+ Run `npx secret-menu --help` for everything, or see [docs/CLI.md](docs/CLI.md).
229
156
 
230
157
  ## Development
231
158
 
232
159
  ```sh
233
160
  npm --prefix ext install
234
161
  npm test # Node 22; unit tests, no browser needed
235
- npm run ext:build
162
+ npm run ext:build # extension output in ext/.output/chrome-mv3
236
163
  ```
237
164
 
238
- Optional, for desktop UI tests only (never needed to run Secret Menu): [agent-desktop](https://www.npmjs.com/package/agent-desktop), installed with `npm i -g agent-desktop`. On macOS, give Accessibility and Screen Recording (System Settings > Privacy & Security) to the app that launches it. [TESTING.md](TESTING.md) lists the UI hooks those tests can rely on.
165
+ The extension lives in `ext/` (WXT, React, Tailwind and shadcn/ui on Base UI). See [TESTING.md](TESTING.md) for tests and [docs/CLI.md](docs/CLI.md#the-test-chrome) to run it in its own test Chrome.
239
166
 
240
167
  ## Uninstall
241
168
 
@@ -243,12 +170,4 @@ Optional, for desktop UI tests only (never needed to run Secret Menu): [agent-de
243
170
  npx secret-menu uninstall
244
171
  ```
245
172
 
246
- This removes only what setup wrote (the native host manifest, if it still points at Secret Menu, and the launcher). Your data in `~/.secret-menu` stays; delete that folder to remove it. Then remove the extension from Chrome. If you used the test Chrome from a clone, run `./bin/secret-menu stop` first.
247
-
248
- ## Credits
249
-
250
- The 🗝️ icon is [Twemoji](https://github.com/jdecked/twemoji) "old key" (1f5dd), © Twitter, Inc. and other contributors, used under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md).
251
-
252
- ## License
253
-
254
- [MIT](LICENSE) © 2026 Israel Vicars. The 🗝️ icon is licensed separately under CC-BY 4.0 (see Credits).
173
+ This removes only what setup wrote. Your data in `~/.secret-menu` stays; delete that folder to remove it. Then remove the extension from Chrome.
package/lib/cli-help.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  export const TAGLINE = "🗝️ Secret Menu connects you and your agent to WebMCP tools across sites.";
2
2
 
3
3
  export const COMMANDS = [
4
- { name: "setup", group: "Setup", usage: "setup [--extension-id ID]", summary: "Connect the Secret Menu extension in your Chrome to this computer (run once)", details: "Installs the native messaging host for your own Chrome (macOS, Linux). The extension ID comes from --extension-id, then SECRET_MENU_EXTENSION_ID, then the ID pinned by the extension's manifest key (ext/EXTENSION_ID). Safe to run again." },
5
- { name: "doctor", group: "Setup", usage: "doctor [--extension-id ID]", summary: "Check Node, the native host manifest, the extension ID and the bridge" },
6
- { name: "uninstall", group: "Setup", usage: "uninstall", summary: "Remove what setup wrote (keeps your data)" },
4
+ { name: "setup", group: "Setup", usage: "setup [--browser NAME ...] [--extension-id ID ...]", summary: "Connect the Secret Menu extension in your Chromium browser(s) to this computer (run once)", details: "Browsers: chrome, chrome-beta, chrome-dev, chrome-canary (macOS), chromium, or all. Repeat --browser or use comma-separated values. Without --browser, existing browser data folders are detected; otherwise Chrome stable is used. Repeat --extension-id for multiple IDs, or use comma-separated values. The pinned ID, and the store ID once set, are always included." },
5
+ { name: "doctor", group: "Setup", usage: "doctor [--browser NAME ...] [--extension-id ID ...]", summary: "Check Node, browser native host manifests, the extension ID and the bridge", details: "Accepts the same --browser names as setup and reports each selected or detected browser." },
6
+ { name: "uninstall", group: "Setup", usage: "uninstall [--browser NAME ...]", summary: "Remove what setup wrote (keeps your data)", details: "Removes the manifest from each selected or detected supported browser only when it points at Secret Menu's launcher." },
7
7
  { name: "launch", group: "Browser", usage: "launch [--headless] [url...]", summary: "Open the Secret Menu Chrome (test profile), optionally at URLs" },
8
8
  { name: "stop", group: "Browser", usage: "stop [--force] [--timeout SECONDS] [--kill]", summary: "Stop Secret Menu Chrome after checking for foreign tabs", details: "Sends SIGTERM and waits (default 8s); exits 4 if Chrome is still up. --kill escalates to SIGKILL; --force skips the foreign-tab check." },
9
9
  { name: "status", group: "Browser", usage: "status", summary: "Show Chrome, bridge, extension, and host status" },
@@ -27,7 +27,7 @@ export const COMMANDS = [
27
27
  { name: "log", group: "Safety", usage: "log [--n N] [--redact]", summary: "Print recent audit records" },
28
28
  { name: "verify", group: "Safety", usage: "verify", summary: "Verify the audit log hash chain" },
29
29
  { name: "mcp", group: "Other", usage: "mcp [--read-only] [--allow-tools LIST]", summary: "Run the MCP server so your agent can use page tools" },
30
- { name: "serve", group: "Other", usage: "serve", summary: "Host test fixtures on 127.0.0.1:8765" },
30
+ { name: "serve", group: "Other", usage: "serve [--port N]", summary: "Host test fixtures on 127.0.0.1:N (default 8765)", details: "--port must be an integer from 1 to 65535." },
31
31
  ];
32
32
 
33
33
  export function commandEntry(name) { return COMMANDS.find((command) => command.name === name); }
package/lib/cli.mjs CHANGED
@@ -11,6 +11,7 @@ import { addToPolicy, decideTool, loadPolicy, normalizeOrigin, removeFromPolicy,
11
11
  import { redactText } from "../lib/redact.mjs";
12
12
  import { normalizePageContext } from "../lib/page-context.mjs";
13
13
  import { classifyTool, explainTools, renderExplain, seedCandidates } from "../lib/explain.mjs";
14
+ import { cacheExplain } from "../lib/learn.mjs";
14
15
  import { installNativeHost } from "../host/install-native-host.mjs";
15
16
  import { listApprovals, readApproval, writeApproval } from "../lib/approvals.mjs";
16
17
  import { parseArgs } from "../lib/args.mjs";
@@ -24,7 +25,7 @@ import { relay } from "../lib/relay.mjs";
24
25
  import { waitForTools } from "../lib/wait.mjs";
25
26
  import { parseStopTimeout, terminate } from "../lib/stop.mjs";
26
27
  import { COMMANDS, TAGLINE, commandHelp, helpText } from "../lib/cli-help.mjs";
27
- import { doctor, nextStepText, resolveExtensionId, setup, uninstall } from "../lib/setup.mjs";
28
+ import { doctor, nextStepText, resolveExtensionIds, setup, uninstall } from "../lib/setup.mjs";
28
29
 
29
30
  const USAGE = helpText(DATA_HOME);
30
31
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -33,6 +34,7 @@ const command = commandArgv.shift();
33
34
  let args = { positionals: [], values: {}, booleans: {}, multi: {} };
34
35
  const flag = (name) => args.booleans[name.slice(2)] === true;
35
36
  const option = (name, fallback) => args.values[name.slice(2)] ?? fallback;
37
+ const options = (name) => args.multi[name.slice(2)] ?? (option(name) == null ? [] : [option(name)]);
36
38
  const tabId = () => option("--tab") == null ? undefined : Number(option("--tab"));
37
39
  const request = (method, params = {}, approval = "n/a") => relay(method, params, { caller: "cli", approval });
38
40
  const die = (message, code = 1) => { console.error(message); process.exit(code); };
@@ -241,6 +243,7 @@ async function cmdExplain() {
241
243
  if (seed.add.length || strict) { const next = addToPolicy(policy, origin, seed.add, { strict }); savePolicy(POLICY_PATH, next); appendAudit(AUDIT_PATH, { ts: new Date().toISOString(), caller: "cli", method: "site_policy", origin, policy: next.sites[origin], via: "explain --seed-allowlist" }); seed.mode = next.sites[origin].mode; }
242
244
  else seed.mode = site?.mode ?? null;
243
245
  }
246
+ try { cacheExplain(DATA_HOME, report); } catch {}
244
247
  if (flag("--json")) return output({ ...report, ...(seed ? { seed } : {}) }, true);
245
248
  console.log(renderExplain(report));
246
249
  if (seed) {
@@ -258,26 +261,35 @@ function cmdDisallow() { const [origin, ...tools] = args.positionals; if (!origi
258
261
  function cmdVerify() { const rows = readAudit(AUDIT_PATH), anchor = readAuditAnchor(AUDIT_PATH), v = verifyAudit(rows, anchor); if (!v.ok && v.truncated) die(`BAD: audit log truncated (expected seq ${v.expectedSeq}, found ${v.foundSeq})`, 6); if (!v.ok) die(`BAD: first invalid audit record seq ${v.seq}`, 6); if (rows.length && !anchor) console.error("warning: audit head anchor is missing; tail truncation cannot be detected"); console.log(`OK: ${v.count} audit records`); }
259
262
  function cmdApprovals() { for (const x of listApprovals(APPROVALS_DIR).filter((a) => a.status === "pending")) console.log(`${x.id}\t${x.createdAt}\t${x.origin}\t${x.tool}\t${JSON.stringify(x.args)}`); }
260
263
  function decide(status) { const id = args.positionals[0]; if (!id) die(`usage: secret-menu ${status === "approved" ? "approve" : "deny"} <id>`, 2); const rec = readApproval(APPROVALS_DIR, id); if (!rec) die(`Approval not found: ${id}`, 2); if (Date.now() - Date.parse(rec.createdAt) > 600000) die(`Approval expired: ${id}`, 2); if (rec.status !== "pending") die(`Approval is already ${rec.status}`, 2); rec.status = status; rec.decidedAt = new Date().toISOString(); writeApproval(APPROVALS_DIR, rec); appendAudit(AUDIT_PATH, { ts: rec.decidedAt, caller: "cli", method: "approval_decision", approvalId: rec.id, tool: rec.tool, origin: rec.origin, argsDigest: sha256(canonical(rec.args)), decision: status }); console.log(`${status}: ${id}`); }
261
- function cmdServe() { const dir = join(root, "test", "fixtures"), mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8" }; createServer((req, res) => { const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname); const path = resolve(dir, `.${pathname === "/" ? "/churn.html" : pathname}`), rel = relative(dir, path); if (rel.startsWith("..") || rel.startsWith("/") || !existsSync(path)) { res.writeHead(404).end("not found"); return; } res.setHeader("content-type", mime[extname(path)] ?? "application/octet-stream"); res.end(readFileSync(path)); }).listen(8765, "127.0.0.1", () => console.log("Serving http://127.0.0.1:8765/")); }
262
- function extensionIdOrDie() { try { return resolveExtensionId({ flag: option("--extension-id") }); } catch (e) { die(e.message, 2); } }
264
+ function cmdServe() {
265
+ const dir = join(root, "test", "fixtures"), rawPort = option("--port", "8765"), port = Number(rawPort);
266
+ if (!Number.isInteger(port) || port < 1 || port > 65535) die("--port must be an integer from 1 to 65535", 2);
267
+ if (!existsSync(dir)) die("test fixtures are missing; this package cannot serve its test pages", 1);
268
+ const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8" };
269
+ const server = createServer((req, res) => { const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname); const path = resolve(dir, `.${pathname === "/" ? "/churn.html" : pathname}`), rel = relative(dir, path); if (rel.startsWith("..") || rel.startsWith("/") || !existsSync(path)) { res.writeHead(404).end("not found"); return; } res.setHeader("content-type", mime[extname(path)] ?? "application/octet-stream"); res.end(readFileSync(path)); });
270
+ server.on("error", (error) => { console.error(error.code === "EADDRINUSE" ? `Port ${port} is already in use. Stop the other server or run: secret-menu serve --port <n>` : `Could not serve fixtures: ${error.message}`); process.exit(1); });
271
+ server.listen(port, "127.0.0.1", () => console.log(`Serving http://127.0.0.1:${port}/`));
272
+ }
273
+ function extensionIdOrDie() { try { return resolveExtensionIds({ flags: options("--extension-id") }); } catch (e) { die(e.message, 2); } }
263
274
  function cmdSetup() {
264
275
  const ext = extensionIdOrDie();
265
- let res; try { res = setup({ dataHome: DATA_HOME, extensionId: ext.id }); } catch (e) { die(e.message); }
276
+ let res; try { res = setup({ dataHome: DATA_HOME, extensionIds: ext.ids, browsers: options("--browser") }); } catch (e) { die(e.message); }
266
277
  console.log("Secret Menu setup: connecting the extension in your Chrome to this computer.");
278
+ for (const browser of res.browsers) console.log(` browser ${browser.browser}: ${browser.hostsDir}`);
267
279
  for (const [what, path] of res.actions) console.log(` ${what.padEnd(9)} ${path}`);
268
280
  if (res.replaced) console.log(` note: the manifest used to point at ${res.replaced}; it now points at the launcher above.`);
269
- console.log(` extension ${ext.id} (${ext.source})\n\n${nextStepText()}`);
281
+ console.log(` extensions ${ext.ids.join(", ")} (${ext.source})\n\n${nextStepText()}`);
270
282
  }
271
283
  async function cmdDoctor() {
272
284
  const ext = extensionIdOrDie();
273
- const checks = await doctor({ dataHome: DATA_HOME, socketPath: SOCKET_PATH, extensionId: ext.id });
285
+ let checks; try { checks = await doctor({ dataHome: DATA_HOME, socketPath: SOCKET_PATH, extensionIds: ext.ids, browsers: options("--browser") }); } catch (e) { die(e.message); }
274
286
  for (const c of checks) { console.log(`${c.ok ? "ok " : "FAIL"} ${c.label}`); if (!c.ok) console.log(` fix: ${c.fix}`); }
275
287
  const failed = checks.filter((c) => !c.ok).length;
276
288
  console.log(failed ? `${failed} check${failed === 1 ? "" : "s"} failed.` : "All checks passed.");
277
289
  if (failed) process.exitCode = 1;
278
290
  }
279
291
  function cmdUninstall() {
280
- let res; try { res = uninstall({ dataHome: DATA_HOME }); } catch (e) { die(e.message); }
292
+ let res; try { res = uninstall({ dataHome: DATA_HOME, browsers: options("--browser") }); } catch (e) { die(e.message); }
281
293
  for (const [what, path] of res.actions) console.log(` ${what.padEnd(9)} ${path}`);
282
294
  console.log(`Left in place: your data in ${DATA_HOME} and anything secret-menu launch wrote.`);
283
295
  }
@@ -297,8 +309,10 @@ async function main() {
297
309
  sites: { booleans: ["--json"] },
298
310
  explain: { values: ["--tab", "--timeout", "--settle"], booleans: ["--json", "--seed-allowlist", "--strict", "--probe"] },
299
311
  allow: { booleans: ["--strict", "--no-strict"] },
300
- setup: { values: ["--extension-id"] },
301
- doctor: { values: ["--extension-id"] },
312
+ setup: { values: ["--extension-id", "--browser"], multi: ["--extension-id", "--browser"] },
313
+ doctor: { values: ["--extension-id", "--browser"], multi: ["--extension-id", "--browser"] },
314
+ uninstall: { values: ["--browser"], multi: ["--browser"] },
315
+ serve: { values: ["--port"] },
302
316
  };
303
317
  if (command == null || command === "--help" || command === "-h") { console.log(USAGE); return; }
304
318
  if (command === "help") { const target = commandArgv[0]; if (!target) { console.log(USAGE); return; } const detail = commandHelp(target); if (!detail) die(USAGE, 2); console.log(`${TAGLINE}\n${detail}`); return; }
package/lib/explain.mjs CHANGED
@@ -217,9 +217,15 @@ function exampleForSpec(spec = {}, key = "value") {
217
217
  }
218
218
  return type === "string" || !type ? `<${key}>` : exampleValue(spec);
219
219
  }
220
+ function exampleCli(cli) {
221
+ const path = String(cli);
222
+ if (/_npx[\\/]/.test(path)) return "npx secret-menu";
223
+ if (/node_modules[\\/]/.test(path)) return "secret-menu";
224
+ return path;
225
+ }
220
226
  export function exampleCommand(c, tool, { cli = "secret-menu", tabId } = {}) {
221
227
  const schema = parseSchema(tool?.inputSchema);
222
- const parts = [cli, "call", c.name];
228
+ const parts = [exampleCli(cli), "call", c.name];
223
229
  if (tabId != null) parts.push("--tab", String(tabId));
224
230
  for (const key of c.required) {
225
231
  const spec = schema.properties?.[key] ?? {};
package/lib/handoff.mjs CHANGED
@@ -44,7 +44,7 @@ export function handoffPrompt({ host, task, abilities, allowChanges = false, all
44
44
  } else parts.push("This site has no WebMCP tools you have allowed; tell me what you would need.");
45
45
  if (asks.length) parts.push(`Before calling ${list(asks)}, show me exactly what will ${plan.ask.some((t) => t.label === "spends money") ? "change or be charged" : "change"} and wait for my yes.`);
46
46
  if (never.length) parts.push(`Never call ${list(never)}.`);
47
- if (signIn) parts.push("If a tool needs sign-in, ask me to sign in to the site in the Secret Menu Chrome window; never ask for my password.");
47
+ if (signIn) parts.push("If a tool needs sign-in, ask me to sign in to the site in your Chrome; never ask for my password.");
48
48
  parts.push("Stop before confirming.");
49
49
  return parts.join(" ");
50
50
  }
package/lib/setup.mjs CHANGED
@@ -10,30 +10,73 @@ import { fileURLToPath } from "node:url";
10
10
 
11
11
  export const HOST_NAME = "com.secretmenu.bridge";
12
12
  export const DEFAULT_EXTENSION_ID = "jfeobmnppdjonobichecmfkabddogeim";
13
+ export const STORE_EXTENSION_ID = "ldijmoknbnamjlegbeplefpmhhfmjakh"; // Chrome Web Store listing ID (dev ID stays DEFAULT_EXTENSION_ID).
14
+ export const KNOWN_EXTENSION_IDS = [DEFAULT_EXTENSION_ID, STORE_EXTENSION_ID].filter(Boolean);
13
15
  export const EXTENSION_ID_RE = /^[a-p]{32}$/;
14
16
  export const MIN_NODE_MAJOR = 22;
15
17
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
16
18
  // What the stable copy needs to run host/native-host.mjs without the npx cache (host imports ../lib/*).
17
19
  const APP_PARTS = ["host", "lib", "bin", "package.json", join("ext", "EXTENSION_ID")];
18
20
 
19
- // Where Chrome (stable, default user data dir) looks for per-user native messaging host manifests.
20
- export function chromeHostsDir({ platform = process.platform, home = homedir(), env = process.env } = {}) {
21
- if (platform === "darwin") return join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
22
- if (platform === "linux") return join(env.XDG_CONFIG_HOME || join(home, ".config"), "google-chrome", "NativeMessagingHosts");
21
+ const BROWSER_FOLDERS = {
22
+ chrome: { mac: "Google/Chrome", linux: "google-chrome" },
23
+ "chrome-beta": { mac: "Google/Chrome Beta", linux: "google-chrome-beta" },
24
+ "chrome-dev": { mac: "Google/Chrome Dev", linux: "google-chrome-unstable" },
25
+ "chrome-canary": { mac: "Google/Chrome Canary", linux: null },
26
+ chromium: { mac: "Chromium", linux: "chromium" },
27
+ };
28
+ export const SUPPORTED_BROWSERS = Object.keys(BROWSER_FOLDERS);
29
+
30
+ function browserPlatform(platform) {
31
+ if (platform === "darwin") return "mac";
32
+ if (platform === "linux") return "linux";
23
33
  if (platform === "win32") throw new Error("secret-menu setup does not support Windows yet (Chrome there needs a registry key, and the bridge needs a named pipe). macOS and Linux are supported.");
24
34
  throw new Error(`secret-menu setup does not support ${platform} yet. macOS and Linux are supported.`);
25
35
  }
26
36
 
27
- export function resolveExtensionId({ flag, env = process.env } = {}) {
28
- const [id, source] = flag ? [flag, "--extension-id"] : env.SECRET_MENU_EXTENSION_ID ? [env.SECRET_MENU_EXTENSION_ID, "SECRET_MENU_EXTENSION_ID"] : [DEFAULT_EXTENSION_ID, "default (the extension's pinned ID)"];
29
- const trimmed = String(id).trim();
30
- if (!EXTENSION_ID_RE.test(trimmed)) throw new Error(`Not a Chrome extension ID (${source}): ${JSON.stringify(trimmed)}. IDs are 32 letters a-p, shown on chrome://extensions with Developer mode on.`);
31
- return { id: trimmed, source };
37
+ export function browserDataDir({ browser = "chrome", platform = process.platform, home = homedir(), env = process.env } = {}) {
38
+ if (!BROWSER_FOLDERS[browser]) throw new Error(`Unknown browser ${JSON.stringify(browser)}. Valid names: ${SUPPORTED_BROWSERS.join(", ")}, all.`);
39
+ const folder = BROWSER_FOLDERS[browser][browserPlatform(platform)];
40
+ if (!folder) throw new Error(`${browser} is not supported on Linux. Valid Linux browsers: chrome, chrome-beta, chrome-dev, chromium.`);
41
+ if (platform === "darwin") return join(home, "Library", "Application Support", folder);
42
+ return join(env.XDG_CONFIG_HOME || join(home, ".config"), folder);
43
+ }
44
+
45
+ // Where a Chromium browser looks for per-user native messaging host manifests.
46
+ export function chromeHostsDir({ platform = process.platform, home = homedir(), env = process.env, browser = "chrome" } = {}) {
47
+ return join(browserDataDir({ browser, platform, home, env }), "NativeMessagingHosts");
48
+ }
49
+
50
+ export function resolveBrowsers({ browsers = [], platform = process.platform, home = homedir(), env = process.env, detect = true } = {}) {
51
+ const requested = browsers.flatMap((value) => String(value).split(",")).map((value) => value.trim()).filter(Boolean);
52
+ for (const browser of requested) {
53
+ if (browser !== "all" && !SUPPORTED_BROWSERS.includes(browser)) throw new Error(`Unknown browser ${JSON.stringify(browser)}. Valid names: ${SUPPORTED_BROWSERS.join(", ")}, all.`);
54
+ if (browser !== "all") browserDataDir({ browser, platform, home, env });
55
+ }
56
+ let names;
57
+ if (requested.includes("all")) names = SUPPORTED_BROWSERS.filter((browser) => BROWSER_FOLDERS[browser][browserPlatform(platform)]);
58
+ else if (requested.length) names = requested;
59
+ else if (detect) names = SUPPORTED_BROWSERS.filter((browser) => BROWSER_FOLDERS[browser][browserPlatform(platform)] && existsSync(browserDataDir({ browser, platform, home, env })));
60
+ else names = [];
61
+ return [...new Set(names.length ? names : ["chrome"])];
32
62
  }
33
63
 
34
- export function setupPaths({ dataHome, platform, home, env } = {}) {
64
+ export function resolveExtensionIds({ flags = [], env = process.env } = {}) {
65
+ const hasFlags = flags.length > 0;
66
+ const source = hasFlags ? "--extension-id" : env.SECRET_MENU_EXTENSION_ID ? "SECRET_MENU_EXTENSION_ID" : "default (the extension's pinned ID)";
67
+ const extra = (hasFlags ? flags : env.SECRET_MENU_EXTENSION_ID ? String(env.SECRET_MENU_EXTENSION_ID).split(",") : []).flatMap((value) => String(value).split(","));
68
+ const ids = [];
69
+ for (const value of [...extra, ...KNOWN_EXTENSION_IDS]) {
70
+ const trimmed = String(value).trim();
71
+ if (!EXTENSION_ID_RE.test(trimmed)) throw new Error(`Not a Chrome extension ID (${source}): ${JSON.stringify(trimmed)}. IDs are 32 letters a-p, shown on chrome://extensions with Developer mode on.`);
72
+ if (!ids.includes(trimmed)) ids.push(trimmed);
73
+ }
74
+ return { ids, source };
75
+ }
76
+
77
+ export function setupPaths({ dataHome, platform, home, env, browser = "chrome" } = {}) {
35
78
  const dir = join(dataHome, "native-host", "setup");
36
- return { dir, app: join(dir, "app"), launcher: join(dir, "secret-menu-host"), receipt: join(dir, "setup.json"), hostsDir: chromeHostsDir({ platform, home, env }), get manifest() { return join(this.hostsDir, `${HOST_NAME}.json`); } };
79
+ return { dir, app: join(dir, "app"), launcher: join(dir, "secret-menu-host"), receipt: join(dir, "setup.json"), browser, hostsDir: chromeHostsDir({ platform, home, env, browser }), get manifest() { return join(this.hostsDir, `${HOST_NAME}.json`); } };
37
80
  }
38
81
 
39
82
  const shQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
@@ -62,12 +105,13 @@ function treeHash(root) {
62
105
  return hash.digest("hex");
63
106
  }
64
107
 
65
- export function manifestFor({ launcher, extensionId }) {
66
- return { name: HOST_NAME, description: "Secret Menu local bridge", path: launcher, type: "stdio", allowed_origins: [`chrome-extension://${extensionId}/`] };
108
+ export function manifestFor({ launcher, extensionIds }) {
109
+ return { name: HOST_NAME, description: "Secret Menu local bridge", path: launcher, type: "stdio", allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`) };
67
110
  }
68
111
 
69
- export function setup({ dataHome, extensionId, platform = process.platform, home = homedir(), env = process.env, nodePath = process.execPath, pkgRoot = PKG_ROOT } = {}) {
70
- const p = setupPaths({ dataHome, platform, home, env });
112
+ export function setup({ dataHome, extensionIds, browsers = [], browser, platform = process.platform, home = homedir(), env = process.env, nodePath = process.execPath, pkgRoot = PKG_ROOT } = {}) {
113
+ const browserNames = resolveBrowsers({ browsers: browser ? [browser] : browsers, platform, home, env });
114
+ const p = setupPaths({ dataHome, platform, home, env, browser: browserNames[0] });
71
115
  const actions = [];
72
116
  // 1. A stable copy of the host code, outside the npx cache (which npm may clear at any time).
73
117
  if (existsSync(p.app) && treeHash(p.app) === treeHash(pkgRoot)) actions.push(["unchanged", p.app]);
@@ -85,26 +129,43 @@ export function setup({ dataHome, extensionId, platform = process.platform, home
85
129
  const launcherText = `#!/bin/sh\n# Written by secret-menu setup. Remove with: secret-menu uninstall\nSECRET_MENU_HOME=${shQuote(dataHome)}\nexport SECRET_MENU_HOME\nexec ${shQuote(nodePath)} ${shQuote(script)} "$@"\n`;
86
130
  actions.push([writeIfChanged(p.launcher, launcherText, 0o700), p.launcher]);
87
131
  // 3. The Chrome manifest. Note when it replaces one that points somewhere else (e.g. a dev checkout).
88
- const existing = readJson(p.manifest);
89
- const replaced = existing && existing.path && existing.path !== p.launcher ? existing.path : null;
90
- const manifestText = `${JSON.stringify(manifestFor({ launcher: p.launcher, extensionId }), null, 2)}\n`;
91
- actions.push([writeIfChanged(p.manifest, manifestText), p.manifest]);
132
+ const manifests = [];
133
+ for (const name of browserNames) {
134
+ const browserPath = setupPaths({ dataHome, platform, home, env, browser: name });
135
+ const existing = readJson(browserPath.manifest);
136
+ const replaced = existing && existing.path && existing.path !== p.launcher ? existing.path : null;
137
+ const manifestText = `${JSON.stringify(manifestFor({ launcher: p.launcher, extensionIds }), null, 2)}\n`;
138
+ actions.push([writeIfChanged(browserPath.manifest, manifestText), browserPath.manifest]);
139
+ manifests.push({ browser: name, hostsDir: browserPath.hostsDir, manifest: browserPath.manifest, replaced });
140
+ }
141
+ const replaced = manifests.find((item) => item.replaced)?.replaced ?? null;
92
142
  // 4. A receipt, so uninstall removes only what setup wrote.
93
- const receipt = { manifest: p.manifest, launcher: p.launcher, app: p.app, extensionId, node: nodePath };
143
+ const receipt = { manifests: manifests.map((item) => item.manifest), manifest: manifests[0].manifest, launcher: p.launcher, app: p.app, extensionIds, node: nodePath };
94
144
  actions.push([writeIfChanged(p.receipt, `${JSON.stringify(receipt, null, 2)}\n`), p.receipt]);
95
- return { ...p, manifest: p.manifest, actions, replaced, extensionId };
145
+ return { ...p, manifest: p.manifest, actions, replaced, extensionIds, browsers: manifests };
96
146
  }
97
147
 
98
- export function uninstall({ dataHome, platform = process.platform, home = homedir(), env = process.env } = {}) {
99
- const p = setupPaths({ dataHome, platform, home, env });
148
+ export function uninstall({ dataHome, browsers = [], browser, platform = process.platform, home = homedir(), env = process.env } = {}) {
149
+ const names = resolveBrowsers({ browsers: browser ? [browser] : browsers, platform, home, env });
150
+ const p = setupPaths({ dataHome, platform, home, env, browser: names[0] });
100
151
  const receipt = readJson(p.receipt);
101
- const manifestPath = receipt?.manifest ?? p.manifest;
152
+ const receiptManifests = receipt?.manifests ?? (receipt?.manifest ? [receipt.manifest] : []);
102
153
  const actions = [];
103
- const m = readJson(manifestPath);
104
- if (m && m.path === p.launcher) { rmSync(manifestPath, { force: true }); actions.push(["removed", manifestPath]); }
105
- else if (m) actions.push(["kept", `${manifestPath} (points at ${m.path}, not written by setup)`]);
106
- else actions.push(["absent", manifestPath]);
107
- if (existsSync(p.dir)) { rmSync(p.dir, { recursive: true, force: true }); actions.push(["removed", p.dir]); }
154
+ // Without --browser, also clean every manifest the last setup recorded, even if its browser folder is gone.
155
+ const explicit = (browser ? [browser] : browsers).length > 0;
156
+ const paths = [...new Set([...names.map((name) => setupPaths({ dataHome, platform, home, env, browser: name }).manifest), ...(explicit ? [] : receiptManifests)])];
157
+ for (const manifestPath of paths) {
158
+ const m = readJson(manifestPath);
159
+ if (m && m.path === p.launcher) { rmSync(manifestPath, { force: true }); actions.push(["removed", manifestPath]); }
160
+ else if (m) actions.push(["kept", `${manifestPath} (points at ${m.path}, not written by setup)`]);
161
+ else actions.push(["absent", manifestPath]);
162
+ }
163
+ // Keep the launcher while another browser's manifest (from the last setup) still points at it.
164
+ const remaining = receiptManifests.filter((path) => !paths.includes(path) && readJson(path)?.path === p.launcher);
165
+ if (remaining.length) {
166
+ writeIfChanged(p.receipt, `${JSON.stringify({ ...receipt, manifests: remaining, manifest: remaining[0] }, null, 2)}\n`);
167
+ actions.push(["kept", `${p.dir} (still used by ${remaining.join(", ")})`]);
168
+ } else if (existsSync(p.dir)) { rmSync(p.dir, { recursive: true, force: true }); actions.push(["removed", p.dir]); }
108
169
  else actions.push(["absent", p.dir]);
109
170
  return { actions };
110
171
  }
@@ -122,34 +183,39 @@ function socketReachable(path, timeoutMs = 1500) {
122
183
 
123
184
  // Each check: { ok, label, fix? }. Bridge reachability is informational when nothing else failed:
124
185
  // the bridge only runs while Chrome has the extension loaded.
125
- export async function doctor({ dataHome, socketPath, extensionId, platform = process.platform, home = homedir(), env = process.env, nodeVersion = process.versions.node } = {}) {
186
+ export async function doctor({ dataHome, socketPath, extensionIds, browsers = [], browser, platform = process.platform, home = homedir(), env = process.env, nodeVersion = process.versions.node } = {}) {
126
187
  const checks = [];
127
188
  const add = (ok, label, fix) => checks.push({ ok, label, ...(ok ? {} : { fix }) });
128
189
  const major = Number(String(nodeVersion).split(".")[0]);
129
190
  add(major >= MIN_NODE_MAJOR, `Node ${nodeVersion} (needs ${MIN_NODE_MAJOR}+)`, `Install Node ${MIN_NODE_MAJOR} or newer (https://nodejs.org).`);
130
- let p;
131
- try { p = setupPaths({ dataHome, platform, home, env }); }
191
+ let p, browserNames;
192
+ try { browserNames = resolveBrowsers({ browsers: browser ? [browser] : browsers, platform, home, env }); p = setupPaths({ dataHome, platform, home, env, browser: browserNames[0] }); }
132
193
  catch (e) { add(false, "Platform supported", e.message); return checks; }
133
194
  const setupFix = "Run: npx -y secret-menu setup";
134
- const manifest = readJson(p.manifest);
135
- if (!existsSync(p.manifest)) { add(false, `Native host manifest at ${p.manifest}`, setupFix); return checks; }
136
- if (!manifest) { add(false, `Native host manifest parses (${p.manifest})`, `The file is not valid JSON. ${setupFix}`); return checks; }
137
- add(true, `Native host manifest at ${p.manifest}`);
138
- add(manifest.name === HOST_NAME, `Manifest name is ${HOST_NAME}`, setupFix);
139
- let exec = false;
140
- try { exec = statSync(manifest.path).isFile(); accessSync(manifest.path, constants.X_OK); } catch { exec = false; }
141
- add(exec, `Manifest points at an executable (${manifest.path})`, setupFix);
142
- if (exec && manifest.path === p.launcher) {
143
- const text = readText(p.launcher) ?? "";
144
- const quoted = [...text.matchAll(/^exec '((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)'/gm)][0];
145
- const unq = (s) => s.replace(/'\\''/g, "'");
146
- const node = quoted && unq(quoted[1]), script = quoted && unq(quoted[2]);
147
- add(!!node && existsSync(node), `Launcher's Node exists (${node ?? "unknown"})`, `Node moved or was removed (nvm upgrade?). ${setupFix}`);
148
- add(!!script && existsSync(script), `Launcher's host script exists (${script ?? "unknown"})`, setupFix);
195
+ for (const name of browserNames) {
196
+ const browserPath = setupPaths({ dataHome, platform, home, env, browser: name });
197
+ const manifest = readJson(browserPath.manifest);
198
+ if (!existsSync(browserPath.manifest)) { add(false, `Native host manifest at ${browserPath.manifest}${browserNames.length > 1 ? ` (${name})` : ""}`, setupFix); continue; }
199
+ if (!manifest) { add(false, `Native host manifest parses (${browserPath.manifest})`, `The file is not valid JSON. ${setupFix}`); continue; }
200
+ add(true, `Native host manifest at ${browserPath.manifest}${browserNames.length > 1 ? ` (${name})` : ""}`);
201
+ add(manifest.name === HOST_NAME, `Manifest name is ${HOST_NAME}${browserNames.length > 1 ? ` (${name})` : ""}`, setupFix);
202
+ let exec = false;
203
+ try { exec = statSync(manifest.path).isFile(); accessSync(manifest.path, constants.X_OK); } catch { exec = false; }
204
+ add(exec, `Manifest points at an executable (${manifest.path})${browserNames.length > 1 ? ` (${name})` : ""}`, setupFix);
205
+ if (exec && manifest.path === browserPath.launcher) {
206
+ const text = readText(browserPath.launcher) ?? "";
207
+ const quoted = [...text.matchAll(/^exec '((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)'/gm)][0];
208
+ const unq = (s) => s.replace(/'\\''/g, "'");
209
+ const node = quoted && unq(quoted[1]), script = quoted && unq(quoted[2]);
210
+ add(!!node && existsSync(node), `Launcher's Node exists (${node ?? "unknown"})`, `Node moved or was removed (nvm upgrade?). ${setupFix}`);
211
+ add(!!script && existsSync(script), `Launcher's host script exists (${script ?? "unknown"})`, setupFix);
212
+ }
213
+ const origins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : [];
214
+ const missing = extensionIds.filter((id) => !origins.includes(`chrome-extension://${id}/`));
215
+ const label = extensionIds.length === 1 ? `Manifest allows extension ${extensionIds[0]}` : `Manifest allows extensions ${extensionIds.join(", ")}`;
216
+ const suggested = missing.filter((id) => !KNOWN_EXTENSION_IDS.includes(id)).map((id) => `--extension-id ${id}`).join(" ");
217
+ add(missing.length === 0, `${label}${browserNames.length > 1 ? ` (${name})` : ""}`, `It allows ${origins.join(", ") || "nothing"}. Run: npx -y secret-menu setup${suggested ? ` ${suggested}` : ""}`);
149
218
  }
150
- const origins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : [];
151
- const want = `chrome-extension://${extensionId}/`;
152
- add(origins.includes(want), `Manifest allows extension ${extensionId}`, `It allows ${origins.join(", ") || "nothing"}. Run: npx -y secret-menu setup --extension-id ${extensionId}`);
153
219
  const sock = await socketReachable(socketPath);
154
220
  add(sock.ok, `Bridge reachable at ${socketPath}${sock.ok ? "" : ` (${sock.why})`}`, "Open Chrome with the Secret Menu extension enabled; Chrome starts the bridge. If it still fails, check chrome://extensions for errors.");
155
221
  return checks;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "secret-menu",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "MIT",
5
5
  "description": "Let your AI agent use the WebMCP tools sites expose in your Chrome, through the Secret Menu extension.",
6
6
  "type": "module",
@@ -13,7 +13,8 @@
13
13
  "host/",
14
14
  "ext/EXTENSION_ID",
15
15
  "README.md",
16
- "LICENSE"
16
+ "LICENSE",
17
+ "test/fixtures/"
17
18
  ],
18
19
  "engines": {
19
20
  "node": ">=22"
@@ -37,6 +38,7 @@
37
38
  "scripts": {
38
39
  "test": "node --test test/*.test.mjs",
39
40
  "ext:build": "npm --prefix ext run build",
41
+ "ext:zip:store": "npm --prefix ext run zip:store",
40
42
  "ext:dev": "npm --prefix ext run dev"
41
43
  },
42
44
  "mcpName": "io.github.israelvicars/secret-menu"
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8"><title>Secret Menu churn B</title>
3
+ <h1>Full-navigation fixture B</h1><a href="churn.html">Back to churn fixture</a><pre id="log"></pre>
4
+ <script type="module">
5
+ const log = document.querySelector("#log");
6
+ if (!document.modelContext) log.textContent = "WebMCP unavailable";
7
+ else { await document.modelContext.registerTool({ name: "get_b_info", description: "Identify fixture B.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, execute: async () => ({ fixture: "b", url: location.href }) }); log.textContent = "registered get_b_info"; }
8
+ </script>
@@ -0,0 +1,35 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>Secret Menu churn fixture</title>
4
+ <h1>Secret Menu churn fixture</h1>
5
+ <button id="signin">Sign in (fake)</button> <button id="signout">Sign out</button>
6
+ <a href="#/checkout">Checkout route</a> · <a href="#/home">Home route</a> · <a href="churn-b.html">Full navigation</a>
7
+ <pre id="log"></pre>
8
+ <script type="module">
9
+ const loaded = new Date().toISOString(), log = (s) => document.querySelector("#log").textContent += `${new Date().toISOString()} ${s}\n`;
10
+ const mc = document.modelContext; if (!mc) { log("WebMCP unavailable"); throw new Error("document.modelContext missing"); }
11
+ const active = new Map(); let cartCount = 0;
12
+ const session = () => ({ loggedIn: localStorage.getItem("secret_menu_user") === "demo", user: localStorage.getItem("secret_menu_user"), cartCount, sessionCookie: document.cookie.includes("secret_menu_session="), localStorageSession: Boolean(localStorage.getItem("secret_menu_user")), pageLoadedAt: loaded });
13
+ async function add(tool) {
14
+ if (active.has(tool.name)) return;
15
+ const controller = new AbortController(); let method = "signal";
16
+ try { await mc.registerTool(tool, { signal: controller.signal }); active.set(tool.name, { controller, method }); }
17
+ catch { await mc.registerTool(tool); method = typeof mc.unregisterTool === "function" ? "unregisterTool" : "cannot unregister"; active.set(tool.name, { method }); }
18
+ log(`registered ${tool.name}; removal: ${method}`);
19
+ }
20
+ async function remove(name) { const item = active.get(name); if (!item) return; try { if (item.method === "signal") item.controller.abort(); else if (item.method === "unregisterTool") await mc.unregisterTool(name); else { log(`cannot unregister ${name}`); return; } active.delete(name); log(`removed ${name} via ${item.method}`); } catch (e) { log(`remove ${name} failed: ${e.message}`); } }
21
+ const object = (properties = {}, required = []) => ({ type: "object", properties, required });
22
+ await add({ name: "get_status", description: "Report the fake session and cart state.", inputSchema: object(), annotations: { readOnlyHint: true }, execute: async () => session() });
23
+ await add({ name: "fake_sign_in", description: "Create a fake cookie and localStorage session.", inputSchema: object(), annotations: { readOnlyHint: false }, execute: async () => { document.cookie = `secret_menu_session=demo-${crypto.randomUUID()}; path=/; max-age=86400`; localStorage.setItem("secret_menu_user", "demo"); await sync(); return session(); } });
24
+ async function sync() {
25
+ if (session().loggedIn) {
26
+ await add({ name: "get_account", description: "Read the fake signed-in account.", inputSchema: object(), annotations: { readOnlyHint: true }, execute: async () => ({ user: "demo" }) });
27
+ await add({ name: "add_to_cart", description: "Add an item to the fake cart.", inputSchema: object({ item: { type: "string", enum: ["tea", "coffee", "scone"], description: "Item to add" }, qty: { type: "integer", default: 1, description: "Quantity" } }, ["item"]), annotations: { readOnlyHint: false, consequentialHint: true }, execute: async ({ item, qty = 1 }) => ({ item, qty, cartCount: cartCount += qty }) });
28
+ } else { await remove("get_account"); await remove("add_to_cart"); }
29
+ if (location.hash === "#/checkout") await add({ name: "checkout", description: "Complete fake checkout.", inputSchema: object({ confirm: { type: "boolean", description: "Confirm checkout" } }, ["confirm"]), annotations: { readOnlyHint: false, consequentialHint: true }, execute: async ({ confirm }) => ({ checkedOut: confirm, cartCount }) }); else await remove("checkout");
30
+ }
31
+ document.querySelector("#signin").onclick = async () => { document.cookie = `secret_menu_session=demo-${crypto.randomUUID()}; path=/; max-age=86400`; localStorage.setItem("secret_menu_user", "demo"); await sync(); };
32
+ document.querySelector("#signout").onclick = async () => { document.cookie = "secret_menu_session=; path=/; max-age=0"; localStorage.removeItem("secret_menu_user"); await sync(); };
33
+ addEventListener("hashchange", sync); addEventListener("storage", sync); await sync();
34
+ setTimeout(() => add({ name: "late_tool", description: "Appears three seconds after page load.", inputSchema: object(), annotations: { readOnlyHint: true }, execute: async () => ({ late: true }) }), 3000);
35
+ </script>
@@ -0,0 +1,4 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>Secret Menu empty fixture</title>
4
+ <h1>No WebMCP tools here</h1>
@@ -0,0 +1,73 @@
1
+ {
2
+ "schema": "secret-menu.fixture-expected/v1",
3
+ "fixture": "test/fixtures/explain.html",
4
+ "url": "http://127.0.0.1:8765/explain.html",
5
+ "title": "Secret Menu explain fixture",
6
+ "note": "Rows the popup shows for this fixture, in order. Generated from explain-tools.js with lib/explain.mjs; checked by test/fixture-expected.test.mjs.",
7
+ "counts": {
8
+ "tools": 14,
9
+ "safe": 6,
10
+ "view": 0,
11
+ "signIn": 1,
12
+ "changes": 4,
13
+ "spends": 3
14
+ },
15
+ "abilities": [
16
+ {
17
+ "title": "Search products, see product and see store hours (+2 more)",
18
+ "label": "safe",
19
+ "labelFrom": "mixed: 3 from site hint, 2 guessed",
20
+ "toolNames": [
21
+ "fetch_reviews",
22
+ "get_product",
23
+ "get_store_hours",
24
+ "get_wishlist",
25
+ "search_products"
26
+ ]
27
+ },
28
+ {
29
+ "title": "Open help",
30
+ "label": "safe",
31
+ "labelFrom": "guessed",
32
+ "toolNames": [
33
+ "open_help"
34
+ ]
35
+ },
36
+ {
37
+ "title": "See your orders",
38
+ "label": "needs sign-in",
39
+ "labelFrom": "guessed",
40
+ "toolNames": [
41
+ "list_my_orders"
42
+ ]
43
+ },
44
+ {
45
+ "title": "Delete account, frobnicate and update profile",
46
+ "label": "changes data",
47
+ "labelFrom": "mixed: 1 from site hint, 2 guessed",
48
+ "toolNames": [
49
+ "delete_account",
50
+ "frobnicate",
51
+ "update_profile"
52
+ ]
53
+ },
54
+ {
55
+ "title": "Buy now, get gift card and place order",
56
+ "label": "spends money",
57
+ "labelFrom": "mixed: 1 from site hint, 2 guessed",
58
+ "toolNames": [
59
+ "buy_now",
60
+ "get_gift_card",
61
+ "place_order"
62
+ ]
63
+ },
64
+ {
65
+ "title": "Sign-in demo",
66
+ "label": "changes data",
67
+ "labelFrom": "guessed",
68
+ "toolNames": [
69
+ "sign_in_demo"
70
+ ]
71
+ }
72
+ ]
73
+ }
@@ -0,0 +1,25 @@
1
+ // Pinned tool list for explain.html (the deterministic test fixture). Imported by the page and by
2
+ // test/fixture-expected.test.mjs, which checks it against explain-expected.json. Change both together.
3
+ const signedIn = () => localStorage.getItem("explain_user") === "demo";
4
+ const object = (properties = {}, required = []) => ({ type: "object", properties, required });
5
+ const ok = (x) => async () => x;
6
+ export const tools = [
7
+ // labeled read-only
8
+ { name: "get_product", description: "Read one product by id.", inputSchema: object({ id: { type: "string", description: "Product id" } }, ["id"]), annotations: { readOnlyHint: true }, execute: ok({ id: "p1", name: "Tea" }) },
9
+ { name: "fetch_reviews", description: "Customer reviews for a product (user-written text).", inputSchema: object({ id: { type: "string" } }, ["id"]), annotations: { readOnlyHint: true, untrustedContentHint: true }, execute: ok({ reviews: [] }) },
10
+ { name: "get_wishlist", description: "Your saved items.", inputSchema: object(), annotations: { readOnlyHint: true }, execute: async () => signedIn() ? { items: ["tea"] } : { ok: false, error: "login_required" } },
11
+ // unlabeled reads
12
+ { name: "search_products", description: "Search the catalog by keyword. Public, no login.", inputSchema: object({ q: { type: "string", description: "Keyword" } }, ["q"]), execute: ok({ results: [] }) },
13
+ { name: "get_store_hours", description: "Hours.", inputSchema: object(), execute: ok({ open: "9-5" }) },
14
+ { name: "list_my_orders", description: "Requires sign-in. Past orders with status.", inputSchema: object(), execute: ok({ orders: [] }) },
15
+ // unlabeled changes / spends
16
+ { name: "update_profile", description: "Requires sign-in. Update the display name.", inputSchema: object({ display_name: { type: "string" } }, ["display_name"]), execute: ok({ ok: true }) },
17
+ { name: "buy_now", description: "SENSITIVE: charges the card on file. Requires login and confirm true.", inputSchema: object({ product_id: { type: "string" }, confirm: { type: "boolean" } }, ["product_id", "confirm"]), execute: ok({ ok: false, error: "fixture never charges" }) },
18
+ { name: "get_gift_card", description: "Issue a gift card.", inputSchema: object({ amount: { type: "integer" }, confirm: { type: "boolean" } }, ["amount", "confirm"]), execute: ok({ ok: false }) },
19
+ { name: "open_help", description: "Navigate to the help page.", inputSchema: object(), execute: ok({ ok: true }) },
20
+ { name: "frobnicate", description: "Does the thing.", inputSchema: object(), execute: ok({ ok: true }) },
21
+ { name: "sign_in_demo", description: "Fake sign in for the demo shop.", inputSchema: object(), execute: async () => { localStorage.setItem("explain_user", "demo"); return { ok: true }; } },
22
+ // labeled changes
23
+ { name: "place_order", description: "Place the order in the cart.", inputSchema: object({ confirm: { type: "boolean" } }, ["confirm"]), annotations: { readOnlyHint: false, consequentialHint: true }, execute: ok({ ok: false }) },
24
+ { name: "delete_account", description: "Permanently delete the account.", inputSchema: object({ confirm: { type: "boolean" } }, ["confirm"]), annotations: { readOnlyHint: false, destructiveHint: true }, execute: ok({ ok: false }) },
25
+ ];
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>Secret Menu explain fixture</title>
4
+ <meta name="description" content="A pretend shop for testing secret-menu explain: search products, order, and manage an account.">
5
+ <meta property="og:site_name" content="Secret Menu Test Shop">
6
+ <meta property="og:type" content="website">
7
+ <h1>Secret Menu explain fixture</h1>
8
+ <p>Fake shop with labeled, unlabeled and spending tools. Nothing here is real.</p>
9
+ <button id="signin">Sign in (fake)</button>
10
+ <pre id="log"></pre>
11
+ <script type="module">
12
+ const mc = document.modelContext; const log = (s) => document.querySelector("#log").textContent += `${s}\n`;
13
+ if (!mc) { log("WebMCP unavailable"); throw new Error("document.modelContext missing"); }
14
+ const { tools } = await import("./explain-tools.js");
15
+ for (const t of tools) { try { await mc.registerTool(t); log(`registered ${t.name}`); } catch (e) { log(`FAILED ${t.name}: ${e.message}`); } }
16
+ document.querySelector("#signin").onclick = () => localStorage.setItem("explain_user", "demo");
17
+ </script>