frizz 0.5.0 → 0.6.0
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 +3 -3
- package/dist/claude-agent-broker.js +50 -7
- package/dist/dev-child.js +8820 -1420
- package/dist/frizz.js +7589 -240
- package/package.json +2 -1
- package/runtime/cc-worker/DECISIONS.md +2 -2
- package/runtime/cc-worker/bin/browser-mcp-tools.json +1192 -0
- package/runtime/cc-worker/bin/browser-mcp.mjs +730 -0
- package/runtime/cc-worker/bin/frizz-mcp.mjs +16 -5
- package/runtime/cc-worker/hooks/deny-ask.mjs +3 -3
- package/runtime/cc-worker/hooks/deny-plan.mjs +5 -4
- package/runtime/cc-worker/hooks/perm-policy.mjs +1 -1
- package/web-dist/assets/{TerminalPane-WcpgmCA-.js → TerminalPane-GfnCbZQ8.js} +1 -1
- package/web-dist/assets/{abnfDiagram-VRR7QNED-Caecklbz.js → abnfDiagram-VRR7QNED-By12QfQW.js} +1 -1
- package/web-dist/assets/architecture-TIHT7OUA-XOYod28l.js +1 -0
- package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-BEk0fg8C.js → architectureDiagram-ZJ3FMSHR-CcXU45QO.js} +1 -1
- package/web-dist/assets/{blockDiagram-677ZJIJ3-B4yq3xiy.js → blockDiagram-677ZJIJ3-CmzyO2OF.js} +1 -1
- package/web-dist/assets/{c4Diagram-LMCZKHZV-DaA5NBkz.js → c4Diagram-LMCZKHZV-DFGSW_ZS.js} +1 -1
- package/web-dist/assets/channel-Bqn3pFKp.js +1 -0
- package/web-dist/assets/{chunk-32BRIVSS-2XsSs8F_.js → chunk-32BRIVSS-whgaFxPY.js} +1 -1
- package/web-dist/assets/{chunk-52WLFC77-DWJfECP3.js → chunk-52WLFC77-DCT0MLgl.js} +1 -1
- package/web-dist/assets/{chunk-C7G6YPKG-Bz07eaGt.js → chunk-C7G6YPKG-Br0OaxIX.js} +1 -1
- package/web-dist/assets/{chunk-EX3LRPZG-D78kZEMM.js → chunk-EX3LRPZG-CUnQkdEF.js} +1 -1
- package/web-dist/assets/{chunk-FWX5IMBZ-CeCa17CW.js → chunk-FWX5IMBZ-D1NqKRFr.js} +2 -2
- package/web-dist/assets/{chunk-HOUHSVGY-9H4GFuBa.js → chunk-HOUHSVGY-7ZL78OrO.js} +1 -1
- package/web-dist/assets/{chunk-ICXQ74PX--Aa17r4X.js → chunk-ICXQ74PX-QSgBAuYw.js} +1 -1
- package/web-dist/assets/{chunk-MOJQB5TN-C3vXhsPV.js → chunk-MOJQB5TN-DOI-0kaG.js} +1 -1
- package/web-dist/assets/{chunk-OGEWGWER-G4kZRBkX.js → chunk-OGEWGWER-CZ3ED-QB.js} +1 -1
- package/web-dist/assets/{chunk-PUDLZKDR-DWd5kvyM.js → chunk-PUDLZKDR-BQJElUFX.js} +1 -1
- package/web-dist/assets/{chunk-Q4XR5HBZ-u55p39Up.js → chunk-Q4XR5HBZ-5B6Qs7rQ.js} +1 -1
- package/web-dist/assets/{chunk-V7JOEXUC-BU4YBmV4.js → chunk-V7JOEXUC-DkMaAMFK.js} +1 -1
- package/web-dist/assets/{chunk-VAUOI2AC-VXcmT20q.js → chunk-VAUOI2AC-Bvq-8GYO.js} +1 -1
- package/web-dist/assets/{chunk-VR4S4FIN-CSjEXLOC.js → chunk-VR4S4FIN-DRVTU-HZ.js} +1 -1
- package/web-dist/assets/{chunk-WYO6CB5R-4WGk2dpL.js → chunk-WYO6CB5R-CRxI5d1E.js} +1 -1
- package/web-dist/assets/{chunk-ZGVPDNZ5-D5Xl8ZV6.js → chunk-ZGVPDNZ5-D_3ow6xU.js} +1 -1
- package/web-dist/assets/classDiagram-OUVF2IWQ-DEMCehy_.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-DEMCehy_.js +1 -0
- package/web-dist/assets/{cynefin-VYW2F7L2-O6wB49wK.js → cynefin-VYW2F7L2-Bexf41pD.js} +1 -1
- package/web-dist/assets/{cynefinDiagram-TSTJHNR4-pd6ZogNC.js → cynefinDiagram-TSTJHNR4-9gbbl1EH.js} +1 -1
- package/web-dist/assets/{dagre-VKFMJZFB-D8W1S6fw.js → dagre-VKFMJZFB-B04shbL0.js} +1 -1
- package/web-dist/assets/{diagram-FQU43EPY-Bm1NOkHd.js → diagram-FQU43EPY-CGXlhEsB.js} +1 -1
- package/web-dist/assets/{diagram-G47NLZAW-CjwnXTIc.js → diagram-G47NLZAW-DmPCeTOa.js} +1 -1
- package/web-dist/assets/{diagram-NH7WQ7WH-DH1jZ_mu.js → diagram-NH7WQ7WH-D8Hthvi7.js} +1 -1
- package/web-dist/assets/{diagram-OA4YK3LP-B-s969Q_.js → diagram-OA4YK3LP-DbLR6tfL.js} +1 -1
- package/web-dist/assets/{diagram-WEI45ONY-BU3lv6cL.js → diagram-WEI45ONY-B6YWev_0.js} +1 -1
- package/web-dist/assets/{ebnfDiagram-CCIWWBDH-FsAeyqcC.js → ebnfDiagram-CCIWWBDH-8G1uf683.js} +1 -1
- package/web-dist/assets/{erDiagram-Q63AITRT-BRvg5Y9d.js → erDiagram-Q63AITRT-U_VRdQHz.js} +1 -1
- package/web-dist/assets/eventmodeling-45OFAUF4-DIpO2jQx.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-aERDdceE.js +1 -0
- package/web-dist/assets/{ganttDiagram-NO4QXBWP-eATKNh9U.js → ganttDiagram-NO4QXBWP-Cnsi9uVf.js} +1 -1
- package/web-dist/assets/{gitGraph-TEB2WS4Q-CNYqsua8.js → gitGraph-TEB2WS4Q-DYf7ka-G.js} +1 -1
- package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-BUxgluf8.js → gitGraphDiagram-IHSO6WYX-Bvmr7dEH.js} +1 -1
- package/web-dist/assets/index-Czv3R-v0.js +486 -0
- package/web-dist/assets/index-D8iyWUZ0.css +1 -0
- package/web-dist/assets/{info-DKCQHKI2-HsVLwo1c.js → info-DKCQHKI2-CdP9Q9r0.js} +1 -1
- package/web-dist/assets/{infoDiagram-FWYZ7A6U-D7U65xDp.js → infoDiagram-FWYZ7A6U-RBcOH3Pi.js} +1 -1
- package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-BsfIYXjX.js → ishikawaDiagram-FXEZZL3T-zS0gy3fS.js} +1 -1
- package/web-dist/assets/{journeyDiagram-5HDEW3XC-CpkREKol.js → journeyDiagram-5HDEW3XC-cOciJlFY.js} +1 -1
- package/web-dist/assets/{kanban-definition-HUTT4EX6-2M3KW6Gw.js → kanban-definition-HUTT4EX6-B-v0XClo.js} +1 -1
- package/web-dist/assets/{line-Cs7zWs24.js → line-z44YXdci.js} +1 -1
- package/web-dist/assets/{mermaid-parser.core-Dw-IECV5.js → mermaid-parser.core-0Bg1x4p0.js} +3 -3
- package/web-dist/assets/{mermaid.core-C1lrddjT.js → mermaid.core-ByFqgcIE.js} +3 -3
- package/web-dist/assets/{mindmap-definition-LN4V7U3C-gVS5On1g.js → mindmap-definition-LN4V7U3C-DiGaAQFh.js} +1 -1
- package/web-dist/assets/{packet-7NZHBO7P-Tti1-UWA.js → packet-7NZHBO7P-Bv8ILo_S.js} +1 -1
- package/web-dist/assets/{pegDiagram-2B236MQR-DE5in93G.js → pegDiagram-2B236MQR-CtOUW7n0.js} +1 -1
- package/web-dist/assets/{pie-RZYD4A2V-DfLNkvHl.js → pie-RZYD4A2V-Dz4Bm-Vx.js} +1 -1
- package/web-dist/assets/{pieDiagram-ENE6RG2P-DcqSqJqb.js → pieDiagram-ENE6RG2P-VMY_CK3i.js} +1 -1
- package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-DR-nRPgN.js → quadrantDiagram-ABIIQ3AL-V7HZxM7j.js} +1 -1
- package/web-dist/assets/{radar-I7S5WNFK-DGLDBfPd.js → radar-I7S5WNFK-CWk6RruH.js} +1 -1
- package/web-dist/assets/{railroad-3IZDKUUU-BaiGEHlV.js → railroad-3IZDKUUU-m_8st_Ez.js} +1 -1
- package/web-dist/assets/railroad-abnf-AHOZXSZD-DIaRscxr.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-CeHpbK-W.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-CbhVIbOj.js +1 -0
- package/web-dist/assets/{railroadDiagram-RFXS5EU6-82aEev5_.js → railroadDiagram-RFXS5EU6-Dz80V-7-.js} +1 -1
- package/web-dist/assets/{requirementDiagram-TGXJPOKE-Bs8opBRj.js → requirementDiagram-TGXJPOKE-DrDDgTwm.js} +1 -1
- package/web-dist/assets/{sankeyDiagram-HTMAVEWB-gOb3FIr8.js → sankeyDiagram-HTMAVEWB-D4SeC-Bi.js} +1 -1
- package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-6DvjtRHR.js → sequenceDiagram-DBY2YBRQ-CA-ZO_qp.js} +1 -1
- package/web-dist/assets/{stateDiagram-2N3HPSRC-Cg2LWdUf.js → stateDiagram-2N3HPSRC-C_ZYvV2s.js} +1 -1
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-BLevFa3H.js +1 -0
- package/web-dist/assets/{swimlanes-5IMT3BWC-BDpPdYEl.js → swimlanes-5IMT3BWC-DGMDseYx.js} +1 -1
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-00mQRq57.js +8 -0
- package/web-dist/assets/{timeline-definition-FHXFAJF6-BSjfeutv.js → timeline-definition-FHXFAJF6-CEiMp3_j.js} +1 -1
- package/web-dist/assets/{treeView-QDETBFTQ-DwgPy6V3.js → treeView-QDETBFTQ-BSspY0US.js} +1 -1
- package/web-dist/assets/{treemap-6X3UGDF4-7eiifTKW.js → treemap-6X3UGDF4-DXNQxmQ7.js} +1 -1
- package/web-dist/assets/{vennDiagram-L72KCM5P-9u4AhcnV.js → vennDiagram-L72KCM5P-C-khqkdm.js} +1 -1
- package/web-dist/assets/{wardley-OPB4EBWU-DqNb5x9U.js → wardley-OPB4EBWU-B9QCQ_Tm.js} +1 -1
- package/web-dist/assets/{wardleyDiagram-EHGQE667-DlJ7h3vg.js → wardleyDiagram-EHGQE667-vhVpAUzh.js} +1 -1
- package/web-dist/assets/{xychartDiagram-FW5EYKEG-B_9ukg2S.js → xychartDiagram-FW5EYKEG-B8ev9zqs.js} +1 -1
- package/web-dist/index.html +2 -2
- package/web-dist/assets/architecture-TIHT7OUA-BTy2nvYL.js +0 -1
- package/web-dist/assets/channel-WghlT4VS.js +0 -1
- package/web-dist/assets/classDiagram-OUVF2IWQ-D21-Dy4r.js +0 -1
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-D21-Dy4r.js +0 -1
- package/web-dist/assets/eventmodeling-45OFAUF4-RhX2WA7g.js +0 -1
- package/web-dist/assets/flowDiagram-23GEKE2U-C7N14tdV.js +0 -1
- package/web-dist/assets/index-BFuReKr0.js +0 -368
- package/web-dist/assets/index-BH9U3Zlg.css +0 -1
- package/web-dist/assets/railroad-abnf-AHOZXSZD-BCeoIOWA.js +0 -1
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-lZpXJLD8.js +0 -1
- package/web-dist/assets/railroad-peg-LSFZ7HO6-CaUBrHTL.js +0 -1
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-Digf9CWZ.js +0 -1
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-DEZRDWHA.js +0 -8
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
/**
|
|
4
|
+
* browser-mcp — frizz's LAZY PROXY in front of the upstream `chrome-devtools-mcp` server.
|
|
5
|
+
*
|
|
6
|
+
* Every frizz worker mounts a browser MCP server, because the runtime release gate needs a browser on
|
|
7
|
+
* any machine and neither backend can assume the operator configured one. Most workers never open a
|
|
8
|
+
* page. Measured on the maintainer's machine 2026-08-19, mounting the real thing eagerly through
|
|
9
|
+
* `npx -y chrome-devtools-mcp@latest` cost **6.07 GB across 78 processes** — 32% of frizz's entire
|
|
10
|
+
* footprint — split in two:
|
|
11
|
+
*
|
|
12
|
+
* - 39 × 89 MB the real server, started at session start whether or not a browser is ever opened;
|
|
13
|
+
* - 39 × 70 MB the `npm exec` shim `npx` leaves behind after it execs the real one. Pure waste:
|
|
14
|
+
* no behaviour is attached to it at all.
|
|
15
|
+
*
|
|
16
|
+
* This script replaces BOTH. It is a dependency-free stdio MCP server that:
|
|
17
|
+
*
|
|
18
|
+
* 1. answers `initialize`, `tools/list`, `ping` and `logging/setLevel` ITSELF, from a tool-schema
|
|
19
|
+
* snapshot on disk — so a worker's tool registry is complete at session start with nothing
|
|
20
|
+
* spawned but this ~17 MB node process;
|
|
21
|
+
* 2. spawns the real server LAZILY, on the first `tools/call`, as `node <resolved bin>` — no npx,
|
|
22
|
+
* no npm shim left running;
|
|
23
|
+
* 3. proxies everything transparently from then on, in BOTH directions.
|
|
24
|
+
*
|
|
25
|
+
* ── WHY THE CLIENT'S OWN `initialize` PARAMS ARE REPLAYED VERBATIM ────────────────────────────────
|
|
26
|
+
* A proxy that introduces itself as the client is not transparent, and the failure is silent rather
|
|
27
|
+
* than loud. chrome-devtools-mcp reads the client's negotiated capabilities: with no `roots`
|
|
28
|
+
* capability it prints "File-writing tools will be restricted to the OS temp directory" on stderr and
|
|
29
|
+
* quietly confines `take_screenshot` there. Claude Code DOES negotiate roots, so a proxy that sent its
|
|
30
|
+
* own handshake would take every worker's screenshots away without failing anything. So the client's
|
|
31
|
+
* `initialize` params are stored and handed to the child unchanged, and the child's own requests back
|
|
32
|
+
* up the wire (`roots/list`, sampling, elicitation) are forwarded to the real client and their
|
|
33
|
+
* responses forwarded down. The only message this process ever authors on the child's stdin is its own
|
|
34
|
+
* `initialize`, under a reserved string id nothing else uses.
|
|
35
|
+
*
|
|
36
|
+
* ── THE TOOL-SCHEMA SNAPSHOT ──────────────────────────────────────────────────────────────────────
|
|
37
|
+
* The whole saving depends on answering `tools/list` with the real server NOT running, and the schemas
|
|
38
|
+
* only exist inside the real server. So they are cached on disk, keyed by the package version:
|
|
39
|
+
*
|
|
40
|
+
* 1. `<cache>/tools-<version>.json` — harvested at runtime, written once per version per machine;
|
|
41
|
+
* 2. `browser-mcp-tools.json` beside — the snapshot committed to the repo, regenerated by
|
|
42
|
+
* this file `nub scripts/harvest-browser-mcp-tools.mjs` (which is this
|
|
43
|
+
* file under `--frizz-harvest`). It is what makes a machine
|
|
44
|
+
* that has NEVER run frizz answer `tools/list` instantly:
|
|
45
|
+
* without it the first worker would have to install and boot
|
|
46
|
+
* the real server inside the client's MCP-startup window,
|
|
47
|
+
* and a client that gives up there leaves the worker with no
|
|
48
|
+
* browser tools for its whole session.
|
|
49
|
+
* 3. a bounded live harvest — install + boot + `tools/list` + write (1). Reached only
|
|
50
|
+
* when the pinned version moved and (2) was not regenerated.
|
|
51
|
+
* 4. a version-MISMATCHED (2) — stale schemas beat no schemas.
|
|
52
|
+
*
|
|
53
|
+
* Whatever was served, the truth is reconciled the moment the child does start: its real `tools/list`
|
|
54
|
+
* is compared with what we served, and on a difference (1) is rewritten and
|
|
55
|
+
* `notifications/tools/list_changed` is sent so the client refetches.
|
|
56
|
+
*
|
|
57
|
+
* ── WHERE THE PACKAGE COMES FROM ──────────────────────────────────────────────────────────────────
|
|
58
|
+
* `chrome-devtools-mcp` is NOT a dependency of `frizz`: it unpacks to 13 MB (it vendors puppeteer-core
|
|
59
|
+
* and has zero transitive deps), which is dead weight in the published tarball for every user who never
|
|
60
|
+
* dispatches browser work. Instead it is installed ONCE per machine per version, on first use, into
|
|
61
|
+
* `~/.frizz/browser-mcp/pkg/<version>/`, and exec'd from there forever after. That is strictly less
|
|
62
|
+
* network than the `npx -y …@latest` it replaces (which re-resolved `latest` on every single spawn).
|
|
63
|
+
* An install already present anywhere resolvable from this file wins first, so a layout that DOES vend
|
|
64
|
+
* the package needs no download at all.
|
|
65
|
+
*
|
|
66
|
+
* Concurrency is handled without a lock: each process installs into its own `mkdtemp` and then
|
|
67
|
+
* `rename`s it into place, which is atomic. A loser's rename fails on the non-empty target, it throws
|
|
68
|
+
* its copy away, and both processes end up using the winner's.
|
|
69
|
+
*
|
|
70
|
+
* ── PROTOCOL ──────────────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
* MCP over stdio = newline-delimited JSON-RPC 2.0, hand-rolled for the same reasons frizz-mcp.mjs
|
|
72
|
+
* hand-rolls it: the surface is tiny and this ships as one loose .mjs inside the worker plugin, with no
|
|
73
|
+
* build, bundle or resolution step. stdout carries protocol frames and NOTHING else; every diagnostic
|
|
74
|
+
* goes to stderr, where the client logs it.
|
|
75
|
+
*/
|
|
76
|
+
import { spawn } from "node:child_process"
|
|
77
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
78
|
+
import { createRequire } from "node:module"
|
|
79
|
+
import { homedir } from "node:os"
|
|
80
|
+
import { dirname, join } from "node:path"
|
|
81
|
+
import { fileURLToPath } from "node:url"
|
|
82
|
+
|
|
83
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
84
|
+
const SNAPSHOT_PATH = join(HERE, "browser-mcp-tools.json")
|
|
85
|
+
const PROTOCOL_FALLBACK = "2025-06-18"
|
|
86
|
+
/** The id this process uses for the ONE request it authors on the child's stdin. */
|
|
87
|
+
const INIT_ID = "frizz-browser-mcp/initialize"
|
|
88
|
+
const LOG_ID = "frizz-browser-mcp/logging"
|
|
89
|
+
/** How long `tools/list` may block on a live harvest before falling back to stale schemas. */
|
|
90
|
+
const HARVEST_BUDGET_MS = 25_000
|
|
91
|
+
/** How long `npm install` may run. Generous: it is paid once per machine per version. */
|
|
92
|
+
const INSTALL_BUDGET_MS = 300_000
|
|
93
|
+
/** How long the real server gets to answer `initialize` before the call it is holding up gives up. */
|
|
94
|
+
const HANDSHAKE_BUDGET_MS = 60_000
|
|
95
|
+
|
|
96
|
+
// ---- argv ----------------------------------------------------------------------------------------
|
|
97
|
+
// Everything after the frizz-only `--frizz-harvest` mode flag is the upstream server's own argv,
|
|
98
|
+
// forwarded byte-for-byte. `--headless` and `--isolated` ride in there and are NOT this file's to
|
|
99
|
+
// second-guess: see the CHROME_DEVTOOLS_MCP comment in packages/server/src/backend/types.ts for why
|
|
100
|
+
// both are required.
|
|
101
|
+
const rawArgs = process.argv.slice(2)
|
|
102
|
+
const HARVEST = rawArgs.includes("--frizz-harvest")
|
|
103
|
+
const BROWSER_ARGS = rawArgs.filter((a) => a !== "--frizz-harvest")
|
|
104
|
+
|
|
105
|
+
// ---- what package, which version ----------------------------------------------------------------
|
|
106
|
+
// The server stamps FRIZZ_BROWSER_MCP_PACKAGE from the ONE canonical spec (backend/types.ts), so the
|
|
107
|
+
// pin lives there and not in two places. The snapshot is the fallback so this file stays runnable by
|
|
108
|
+
// hand, and `@latest` is the last resort so it degrades to the old behaviour rather than to nothing.
|
|
109
|
+
const snapshot = readJson(SNAPSHOT_PATH)
|
|
110
|
+
const spec = parseSpec(
|
|
111
|
+
process.env.FRIZZ_BROWSER_MCP_PACKAGE ||
|
|
112
|
+
(snapshot?.package && snapshot?.version ? `${snapshot.package}@${snapshot.version}` : "chrome-devtools-mcp@latest"),
|
|
113
|
+
)
|
|
114
|
+
/** Test/ops hook: an explicit install root, so a sandbox never writes to the real `~/.frizz`. */
|
|
115
|
+
const CACHE_DIR = process.env.FRIZZ_BROWSER_MCP_HOME || join(homedir(), ".frizz", "browser-mcp")
|
|
116
|
+
|
|
117
|
+
/** @param {string} value @returns {{name: string, version: string}} */
|
|
118
|
+
function parseSpec(value) {
|
|
119
|
+
const at = value.lastIndexOf("@")
|
|
120
|
+
if (at <= 0) return { name: value, version: "latest" }
|
|
121
|
+
return { name: value.slice(0, at), version: value.slice(at + 1) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @param {string} path @returns {any} */
|
|
125
|
+
function readJson(path) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(readFileSync(path, "utf8"))
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Diagnostics. stderr is where an MCP client is supposed to collect them, but a worker's client keeps
|
|
135
|
+
* that in a per-session log nobody reads, and this process does its most interesting work (installing,
|
|
136
|
+
* harvesting, starting the real server) at moments nobody is watching. `FRIZZ_BROWSER_MCP_TRACE=<path>`
|
|
137
|
+
* appends the same lines to a file so a live worker can be traced without a debugger. Off by default.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} message
|
|
140
|
+
*/
|
|
141
|
+
function note(message) {
|
|
142
|
+
const line = `[frizz browser-mcp ${process.pid}] ${message}\n`
|
|
143
|
+
try {
|
|
144
|
+
process.stderr.write(line)
|
|
145
|
+
} catch {
|
|
146
|
+
/* a closed stderr is not worth dying over */
|
|
147
|
+
}
|
|
148
|
+
const trace = process.env.FRIZZ_BROWSER_MCP_TRACE
|
|
149
|
+
if (trace) {
|
|
150
|
+
try {
|
|
151
|
+
appendFileSync(trace, `${new Date().toISOString()} ${line}`)
|
|
152
|
+
} catch {
|
|
153
|
+
/* a trace that cannot be written is not worth failing a tool call over */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- stdout framing ------------------------------------------------------------------------------
|
|
159
|
+
/** @param {unknown} obj */
|
|
160
|
+
function send(obj) {
|
|
161
|
+
try {
|
|
162
|
+
process.stdout.write(JSON.stringify(obj) + "\n")
|
|
163
|
+
} catch {
|
|
164
|
+
/* the client hung up; the exit handlers will tear the child down */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** @param {string|number} id @param {unknown} result */
|
|
168
|
+
function reply(id, result) {
|
|
169
|
+
send({ jsonrpc: "2.0", id, result })
|
|
170
|
+
}
|
|
171
|
+
/** @param {string|number} id @param {number} code @param {string} message */
|
|
172
|
+
function replyError(id, code, message) {
|
|
173
|
+
send({ jsonrpc: "2.0", id, error: { code, message } })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- the tool-schema cache -----------------------------------------------------------------------
|
|
177
|
+
const cacheFile = () => join(CACHE_DIR, `tools-${spec.version}.json`)
|
|
178
|
+
|
|
179
|
+
/** The registry this process serves `initialize` + `tools/list` from. Resolved once, lazily. */
|
|
180
|
+
let registry = null
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The registry WITHOUT paying for a harvest — the version cache, else the committed snapshot at any
|
|
184
|
+
* version. `initialize` uses only this: a client that does not get its handshake promptly declares the
|
|
185
|
+
* server dead, and the worker then has no browser tools AT ALL for the whole session, which is the
|
|
186
|
+
* one outcome worse than an eagerly-spawned server.
|
|
187
|
+
*/
|
|
188
|
+
function peekRegistry() {
|
|
189
|
+
if (registry) return registry
|
|
190
|
+
const cached = readJson(cacheFile())
|
|
191
|
+
if (cached?.tools?.length) return (registry = cached)
|
|
192
|
+
if (snapshot?.tools?.length && snapshot.version === spec.version) return (registry = snapshot)
|
|
193
|
+
return undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @returns {Promise<{tools: any[], capabilities?: any, serverInfo?: any, version?: string}>} */
|
|
197
|
+
async function loadRegistry() {
|
|
198
|
+
const peeked = peekRegistry()
|
|
199
|
+
if (peeked) return peeked
|
|
200
|
+
// Nothing pinned to this exact version: pay the harvest, bounded, so a client that is waiting on
|
|
201
|
+
// `tools/list` gets an answer either way.
|
|
202
|
+
try {
|
|
203
|
+
const harvested = await withTimeout(harvest(), HARVEST_BUDGET_MS, "tool-schema harvest")
|
|
204
|
+
writeCache(harvested)
|
|
205
|
+
return (registry = harvested)
|
|
206
|
+
} catch (err) {
|
|
207
|
+
note(`could not harvest tool schemas (${errText(err)})`)
|
|
208
|
+
}
|
|
209
|
+
if (snapshot?.tools?.length) {
|
|
210
|
+
// Stale schemas beat none: the tool names and shapes move rarely, and a worker with a slightly
|
|
211
|
+
// out-of-date schema still gets a browser. A worker with an empty registry does not.
|
|
212
|
+
note(`serving the committed snapshot for ${snapshot.package}@${snapshot.version} against a pinned ${spec.version}`)
|
|
213
|
+
return (registry = snapshot)
|
|
214
|
+
}
|
|
215
|
+
return (registry = { tools: [] })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @param {any} value */
|
|
219
|
+
function writeCache(value) {
|
|
220
|
+
try {
|
|
221
|
+
mkdirSync(CACHE_DIR, { recursive: true })
|
|
222
|
+
const tmp = `${cacheFile()}.${process.pid}.tmp`
|
|
223
|
+
writeFileSync(tmp, JSON.stringify(value))
|
|
224
|
+
renameSync(tmp, cacheFile())
|
|
225
|
+
} catch (err) {
|
|
226
|
+
note(`could not write the tool-schema cache (${errText(err)})`)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- resolving / installing the real server ------------------------------------------------------
|
|
231
|
+
/** `bin` may be a string or a map; take the entry named after the package, else the sole one. */
|
|
232
|
+
function binPath(pkgDir) {
|
|
233
|
+
const pkg = readJson(join(pkgDir, "package.json"))
|
|
234
|
+
if (!pkg) return undefined
|
|
235
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[spec.name] ?? Object.values(pkg.bin ?? {})[0]
|
|
236
|
+
if (typeof bin !== "string") return undefined
|
|
237
|
+
const abs = join(pkgDir, bin)
|
|
238
|
+
return existsSync(abs) ? { bin: abs, version: pkg.version } : undefined
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const installRoot = () => join(CACHE_DIR, "pkg", spec.version)
|
|
242
|
+
|
|
243
|
+
/** @returns {{bin: string, version?: string} | undefined} */
|
|
244
|
+
function resolveInstalled() {
|
|
245
|
+
const override = process.env.FRIZZ_BROWSER_MCP_BIN
|
|
246
|
+
if (override && existsSync(override)) return { bin: override }
|
|
247
|
+
const managed = binPath(join(installRoot(), "node_modules", spec.name))
|
|
248
|
+
if (managed) return managed
|
|
249
|
+
// An ambient copy: a layout that vends the package as a real dependency resolves from here.
|
|
250
|
+
try {
|
|
251
|
+
const manifest = createRequire(import.meta.url).resolve(`${spec.name}/package.json`)
|
|
252
|
+
const ambient = binPath(dirname(manifest))
|
|
253
|
+
if (ambient && (spec.version === "latest" || ambient.version === spec.version)) return ambient
|
|
254
|
+
} catch {
|
|
255
|
+
/* not resolvable from here — normal */
|
|
256
|
+
}
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `npm` as an argv, preferring the npm-cli.js beside this node over anything on PATH: the worker's
|
|
262
|
+
* PATH varies by launch context, and this avoids `npm.cmd` + a shell on Windows entirely.
|
|
263
|
+
*/
|
|
264
|
+
function npmArgv() {
|
|
265
|
+
const cli = join(dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")
|
|
266
|
+
const sibling = join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js")
|
|
267
|
+
for (const candidate of [cli, sibling]) if (existsSync(candidate)) return [process.execPath, candidate]
|
|
268
|
+
return [process.platform === "win32" ? "npm.cmd" : "npm"]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Staging directories this process created, so an orderly exit does not leave one behind. A SIGKILL
|
|
273
|
+
* still can, which is what sweepStaleStaging is for.
|
|
274
|
+
*/
|
|
275
|
+
const ownStaging = new Set()
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Delete `.install-*` directories no live install can still own. An install interrupted before its
|
|
279
|
+
* rename — the client killing a probe connection, a machine going to sleep — leaves 13 MB behind
|
|
280
|
+
* forever otherwise, and it happened on the very first real worker run.
|
|
281
|
+
*/
|
|
282
|
+
function sweepStaleStaging() {
|
|
283
|
+
const cutoff = Date.now() - 60 * 60 * 1000
|
|
284
|
+
try {
|
|
285
|
+
for (const entry of readdirSync(CACHE_DIR)) {
|
|
286
|
+
if (!entry.startsWith(".install-")) continue
|
|
287
|
+
const path = join(CACHE_DIR, entry)
|
|
288
|
+
if (ownStaging.has(path)) continue
|
|
289
|
+
try {
|
|
290
|
+
if (statSync(path).mtimeMs < cutoff) rmSync(path, { recursive: true, force: true })
|
|
291
|
+
} catch {
|
|
292
|
+
/* a directory that vanished under us is exactly the outcome we wanted */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
/* no cache dir yet */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @returns {Promise<{bin: string, version?: string}>} */
|
|
301
|
+
async function ensureInstalled() {
|
|
302
|
+
const found = resolveInstalled()
|
|
303
|
+
if (found) return found
|
|
304
|
+
mkdirSync(CACHE_DIR, { recursive: true })
|
|
305
|
+
sweepStaleStaging()
|
|
306
|
+
const staging = mkdtempSync(join(CACHE_DIR, ".install-"))
|
|
307
|
+
ownStaging.add(staging)
|
|
308
|
+
const [cmd, ...pre] = npmArgv()
|
|
309
|
+
const args = [
|
|
310
|
+
...pre,
|
|
311
|
+
"install",
|
|
312
|
+
"--prefix",
|
|
313
|
+
staging,
|
|
314
|
+
`${spec.name}@${spec.version}`,
|
|
315
|
+
"--no-audit",
|
|
316
|
+
"--no-fund",
|
|
317
|
+
"--no-package-lock",
|
|
318
|
+
"--loglevel=error",
|
|
319
|
+
]
|
|
320
|
+
note(`installing ${spec.name}@${spec.version} (one time, into ${installRoot()})`)
|
|
321
|
+
try {
|
|
322
|
+
await run(cmd, args, INSTALL_BUDGET_MS)
|
|
323
|
+
mkdirSync(dirname(installRoot()), { recursive: true })
|
|
324
|
+
try {
|
|
325
|
+
renameSync(staging, installRoot())
|
|
326
|
+
} catch {
|
|
327
|
+
// Another worker won the race and landed its copy first. Its tree is as good as ours.
|
|
328
|
+
rmSync(staging, { recursive: true, force: true })
|
|
329
|
+
}
|
|
330
|
+
ownStaging.delete(staging)
|
|
331
|
+
} catch (err) {
|
|
332
|
+
rmSync(staging, { recursive: true, force: true })
|
|
333
|
+
ownStaging.delete(staging)
|
|
334
|
+
throw new Error(`could not install ${spec.name}@${spec.version}: ${errText(err)}`)
|
|
335
|
+
}
|
|
336
|
+
const installed = resolveInstalled()
|
|
337
|
+
if (!installed) throw new Error(`installed ${spec.name}@${spec.version} but found no runnable bin under ${installRoot()}`)
|
|
338
|
+
return installed
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** @param {string} cmd @param {string[]} args @param {number} timeoutMs */
|
|
342
|
+
function run(cmd, args, timeoutMs) {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] })
|
|
345
|
+
let err = ""
|
|
346
|
+
proc.stdout.on("data", () => {})
|
|
347
|
+
proc.stderr.on("data", (d) => {
|
|
348
|
+
err += d
|
|
349
|
+
})
|
|
350
|
+
const timer = setTimeout(() => {
|
|
351
|
+
proc.kill("SIGKILL")
|
|
352
|
+
reject(new Error(`timed out after ${timeoutMs}ms`))
|
|
353
|
+
}, timeoutMs)
|
|
354
|
+
proc.on("error", (e) => {
|
|
355
|
+
clearTimeout(timer)
|
|
356
|
+
reject(e)
|
|
357
|
+
})
|
|
358
|
+
proc.on("close", (code) => {
|
|
359
|
+
clearTimeout(timer)
|
|
360
|
+
if (code === 0) resolve(undefined)
|
|
361
|
+
else reject(new Error(`exit ${code}${err ? `: ${err.trim().slice(0, 400)}` : ""}`))
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- the child, and the two-way pipe -------------------------------------------------------------
|
|
367
|
+
/** @type {import("node:child_process").ChildProcessWithoutNullStreams | null} */
|
|
368
|
+
let child = null
|
|
369
|
+
/** Single-flight start. Cleared on failure and on exit so a later call can try again. */
|
|
370
|
+
let starting = null
|
|
371
|
+
/** The client's own `initialize` params, replayed to the child verbatim. See the header. */
|
|
372
|
+
let clientInit = null
|
|
373
|
+
/** A `logging/setLevel` the client set before the child existed, replayed on start. */
|
|
374
|
+
let pendingLogLevel = null
|
|
375
|
+
/** Client request ids currently in flight downstream, so a child crash can answer them. */
|
|
376
|
+
const inFlight = new Set()
|
|
377
|
+
/** Requests THIS process authored on the child's stdin. */
|
|
378
|
+
const internal = new Map()
|
|
379
|
+
|
|
380
|
+
/** @param {unknown} obj */
|
|
381
|
+
function sendChild(obj) {
|
|
382
|
+
child?.stdin.write(JSON.stringify(obj) + "\n")
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** @param {string} id @param {string} method @param {unknown} params */
|
|
386
|
+
function childRequest(id, method, params) {
|
|
387
|
+
return new Promise((resolve, reject) => {
|
|
388
|
+
internal.set(id, { resolve, reject })
|
|
389
|
+
sendChild({ jsonrpc: "2.0", id, method, params })
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function onChildLine(line) {
|
|
394
|
+
let msg
|
|
395
|
+
try {
|
|
396
|
+
msg = JSON.parse(line)
|
|
397
|
+
} catch {
|
|
398
|
+
return // a non-protocol line on stdout is not ours to invent meaning for
|
|
399
|
+
}
|
|
400
|
+
const pending = msg.id !== undefined && msg.id !== null ? internal.get(msg.id) : undefined
|
|
401
|
+
if (pending) {
|
|
402
|
+
internal.delete(msg.id)
|
|
403
|
+
if (msg.error) pending.reject(new Error(msg.error.message ?? "child error"))
|
|
404
|
+
else pending.resolve(msg.result)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (msg.id !== undefined && msg.id !== null && msg.method === undefined) inFlight.delete(msg.id)
|
|
408
|
+
send(msg) // responses to the client's requests, the child's own requests, and its notifications
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function onChildGone(reason) {
|
|
412
|
+
const dead = inFlight.size
|
|
413
|
+
child = null
|
|
414
|
+
starting = null
|
|
415
|
+
for (const { reject } of internal.values()) reject(new Error(reason))
|
|
416
|
+
internal.clear()
|
|
417
|
+
for (const id of inFlight) replyError(id, -32000, `${spec.name} exited before answering: ${reason}`)
|
|
418
|
+
inFlight.clear()
|
|
419
|
+
if (dead) note(`the browser server died with ${dead} call(s) in flight (${reason})`)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** @returns {Promise<void>} */
|
|
423
|
+
function ensureChild() {
|
|
424
|
+
if (child) return Promise.resolve()
|
|
425
|
+
if (starting) return starting
|
|
426
|
+
starting = (async () => {
|
|
427
|
+
const { bin } = await ensureInstalled()
|
|
428
|
+
const proc = spawn(process.execPath, [bin, ...BROWSER_ARGS], { stdio: ["pipe", "pipe", "pipe"] })
|
|
429
|
+
child = proc
|
|
430
|
+
let buf = ""
|
|
431
|
+
proc.stdout.setEncoding("utf8")
|
|
432
|
+
proc.stdout.on("data", (chunk) => {
|
|
433
|
+
buf += chunk
|
|
434
|
+
let nl
|
|
435
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
436
|
+
const line = buf.slice(0, nl).trim()
|
|
437
|
+
buf = buf.slice(nl + 1)
|
|
438
|
+
if (line) onChildLine(line)
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
proc.stderr.on("data", (d) => {
|
|
442
|
+
try {
|
|
443
|
+
process.stderr.write(d)
|
|
444
|
+
} catch {
|
|
445
|
+
/* ignore */
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
proc.on("error", (err) => onChildGone(errText(err)))
|
|
449
|
+
proc.on("exit", (code, signal) => onChildGone(signal ? `signal ${signal}` : `exit ${code}`))
|
|
450
|
+
// The CLIENT's handshake, not ours — see the header. Absent (a `tools/call` with no preceding
|
|
451
|
+
// `initialize`, which no real client does) we introduce ourselves honestly.
|
|
452
|
+
const init = clientInit ?? {
|
|
453
|
+
protocolVersion: PROTOCOL_FALLBACK,
|
|
454
|
+
capabilities: {},
|
|
455
|
+
clientInfo: { name: "frizz-browser-mcp", version: "1" },
|
|
456
|
+
}
|
|
457
|
+
const negotiated = await withTimeout(childRequest(INIT_ID, "initialize", init), HANDSHAKE_BUDGET_MS, "the browser server handshake")
|
|
458
|
+
sendChild({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
459
|
+
if (pendingLogLevel) sendChild({ jsonrpc: "2.0", id: LOG_ID, method: "logging/setLevel", params: { level: pendingLogLevel } })
|
|
460
|
+
void reconcileTools(proc, negotiated)
|
|
461
|
+
})()
|
|
462
|
+
starting.catch(() => {
|
|
463
|
+
// A child that spawned but never finished its handshake is not reusable; drop it so the NEXT call
|
|
464
|
+
// gets a clean attempt rather than an inheritance of this failure.
|
|
465
|
+
starting = null
|
|
466
|
+
try {
|
|
467
|
+
child?.kill()
|
|
468
|
+
} catch {
|
|
469
|
+
/* already gone */
|
|
470
|
+
}
|
|
471
|
+
child = null
|
|
472
|
+
})
|
|
473
|
+
return starting
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The moment the real server exists, its `tools/list` is the truth. Compare it with what we served; on
|
|
478
|
+
* a difference, rewrite the version cache and tell the client to refetch. This is what keeps a stale
|
|
479
|
+
* snapshot from being a permanent lie rather than a one-session approximation.
|
|
480
|
+
*/
|
|
481
|
+
async function reconcileTools(proc, negotiated) {
|
|
482
|
+
try {
|
|
483
|
+
const result = await childRequest("frizz-browser-mcp/tools", "tools/list", {})
|
|
484
|
+
if (!result?.tools || child !== proc) return
|
|
485
|
+
const served = registry?.tools
|
|
486
|
+
if (served && JSON.stringify(result.tools) === JSON.stringify(served)) return
|
|
487
|
+
writeCache({
|
|
488
|
+
package: spec.name,
|
|
489
|
+
version: spec.version,
|
|
490
|
+
protocolVersion: negotiated?.protocolVersion ?? registry?.protocolVersion,
|
|
491
|
+
capabilities: negotiated?.capabilities ?? registry?.capabilities,
|
|
492
|
+
serverInfo: negotiated?.serverInfo ?? registry?.serverInfo,
|
|
493
|
+
tools: result.tools,
|
|
494
|
+
})
|
|
495
|
+
registry = { ...(registry ?? {}), tools: result.tools }
|
|
496
|
+
if (!served) return // nothing was ever served to this client, so there is nothing to correct
|
|
497
|
+
note(`tool schemas changed under version ${spec.version}; cache refreshed`)
|
|
498
|
+
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })
|
|
499
|
+
} catch (err) {
|
|
500
|
+
note(`could not reconcile tool schemas (${errText(err)})`)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---- harvest mode --------------------------------------------------------------------------------
|
|
505
|
+
/** Boot the real server once and read its registry out. Used by `--frizz-harvest` AND by loadRegistry. */
|
|
506
|
+
async function harvest() {
|
|
507
|
+
const { bin, version } = await ensureInstalled()
|
|
508
|
+
const proc = spawn(process.execPath, [bin, ...BROWSER_ARGS], { stdio: ["pipe", "pipe", "ignore"] })
|
|
509
|
+
try {
|
|
510
|
+
let buf = ""
|
|
511
|
+
const pending = new Map()
|
|
512
|
+
proc.stdout.setEncoding("utf8")
|
|
513
|
+
proc.stdout.on("data", (chunk) => {
|
|
514
|
+
buf += chunk
|
|
515
|
+
let nl
|
|
516
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
517
|
+
const line = buf.slice(0, nl).trim()
|
|
518
|
+
buf = buf.slice(nl + 1)
|
|
519
|
+
if (!line) continue
|
|
520
|
+
let msg
|
|
521
|
+
try {
|
|
522
|
+
msg = JSON.parse(line)
|
|
523
|
+
} catch {
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
const waiter = pending.get(msg.id)
|
|
527
|
+
if (!waiter) continue
|
|
528
|
+
pending.delete(msg.id)
|
|
529
|
+
if (msg.error) waiter.reject(new Error(msg.error.message ?? "error"))
|
|
530
|
+
else waiter.resolve(msg.result)
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
const ask = (id, method, params) =>
|
|
534
|
+
new Promise((resolve, reject) => {
|
|
535
|
+
pending.set(id, { resolve, reject })
|
|
536
|
+
proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
|
|
537
|
+
})
|
|
538
|
+
proc.on("exit", () => {
|
|
539
|
+
for (const w of pending.values()) w.reject(new Error("the browser server exited during the harvest"))
|
|
540
|
+
pending.clear()
|
|
541
|
+
})
|
|
542
|
+
const init = await ask(1, "initialize", {
|
|
543
|
+
protocolVersion: PROTOCOL_FALLBACK,
|
|
544
|
+
capabilities: { roots: { listChanged: true } },
|
|
545
|
+
clientInfo: { name: "frizz-browser-mcp", version: "1" },
|
|
546
|
+
})
|
|
547
|
+
proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n")
|
|
548
|
+
const list = await ask(2, "tools/list", {})
|
|
549
|
+
return {
|
|
550
|
+
package: spec.name,
|
|
551
|
+
version: version ?? spec.version,
|
|
552
|
+
protocolVersion: init?.protocolVersion ?? PROTOCOL_FALLBACK,
|
|
553
|
+
capabilities: init?.capabilities ?? { tools: { listChanged: true } },
|
|
554
|
+
serverInfo: init?.serverInfo,
|
|
555
|
+
tools: list?.tools ?? [],
|
|
556
|
+
}
|
|
557
|
+
} finally {
|
|
558
|
+
proc.kill()
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---- helpers -------------------------------------------------------------------------------------
|
|
563
|
+
/** @param {Promise<any>} promise @param {number} ms @param {string} what */
|
|
564
|
+
function withTimeout(promise, ms, what) {
|
|
565
|
+
return new Promise((resolve, reject) => {
|
|
566
|
+
const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms)
|
|
567
|
+
promise.then(
|
|
568
|
+
(v) => {
|
|
569
|
+
clearTimeout(timer)
|
|
570
|
+
resolve(v)
|
|
571
|
+
},
|
|
572
|
+
(e) => {
|
|
573
|
+
clearTimeout(timer)
|
|
574
|
+
reject(e)
|
|
575
|
+
},
|
|
576
|
+
)
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @param {unknown} err */
|
|
581
|
+
function errText(err) {
|
|
582
|
+
return err instanceof Error ? err.message : String(err)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---- the client side -----------------------------------------------------------------------------
|
|
586
|
+
/** @param {any} msg */
|
|
587
|
+
async function handle(msg) {
|
|
588
|
+
const { id, method, params } = msg ?? {}
|
|
589
|
+
const isNotification = id === undefined || id === null
|
|
590
|
+
if (process.env.FRIZZ_BROWSER_MCP_TRACE) note(`<- ${method ?? "(response)"} id=${String(id)}`)
|
|
591
|
+
|
|
592
|
+
// A RESPONSE from the client (to a `roots/list` or a sampling request the child made) — never ours
|
|
593
|
+
// to interpret, always the child's to receive.
|
|
594
|
+
if (method === undefined) {
|
|
595
|
+
if (child) sendChild(msg)
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
switch (method) {
|
|
600
|
+
case "initialize": {
|
|
601
|
+
clientInit = params
|
|
602
|
+
const reg = peekRegistry()
|
|
603
|
+
const requested = params?.protocolVersion
|
|
604
|
+
reply(id, {
|
|
605
|
+
protocolVersion: typeof requested === "string" ? requested : reg?.protocolVersion ?? PROTOCOL_FALLBACK,
|
|
606
|
+
capabilities: reg?.capabilities ?? { tools: { listChanged: true } },
|
|
607
|
+
serverInfo: reg?.serverInfo ?? { name: spec.name, version: spec.version },
|
|
608
|
+
})
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
case "notifications/initialized":
|
|
612
|
+
case "initialized":
|
|
613
|
+
return // notification — no reply, and nothing to start
|
|
614
|
+
case "ping":
|
|
615
|
+
if (!isNotification) reply(id, {})
|
|
616
|
+
return
|
|
617
|
+
case "tools/list": {
|
|
618
|
+
// The whole point: answered WITHOUT the real server, unless it happens to be up already.
|
|
619
|
+
if (child) return forward(msg)
|
|
620
|
+
const reg = await loadRegistry()
|
|
621
|
+
reply(id, { tools: reg.tools })
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
case "logging/setLevel":
|
|
625
|
+
pendingLogLevel = params?.level ?? null
|
|
626
|
+
if (child) return forward(msg)
|
|
627
|
+
if (!isNotification) reply(id, {})
|
|
628
|
+
return
|
|
629
|
+
default:
|
|
630
|
+
// ONLY `tools/call` starts the browser. Everything else that reaches here is answered the way the
|
|
631
|
+
// real server answers it — which for this server is `-32601 Method not found`, since it advertises
|
|
632
|
+
// nothing but `logging` and `tools` (verified against 1.7.0 for `server/discover`, `resources/list`
|
|
633
|
+
// and `prompts/list`; all three come back -32601).
|
|
634
|
+
//
|
|
635
|
+
// THIS BRANCH IS WHERE THE WHOLE CHANGE ALMOST DIED. Claude Code opens a short-lived probe
|
|
636
|
+
// connection and sends `server/discover` (id `server-discover-probe-1`) BEFORE the real session.
|
|
637
|
+
// While `default:` meant "start the browser", every worker paid a full install + server boot at
|
|
638
|
+
// session start, in a process the client then killed — which also leaked its half-finished install
|
|
639
|
+
// staging. Caught on 2026-08-19 only by tracing a real worker's proxy (FRIZZ_BROWSER_MCP_TRACE);
|
|
640
|
+
// every unit test and the whole tool-call path passed while it was happening.
|
|
641
|
+
if (isNotification) {
|
|
642
|
+
if (child) sendChild(msg)
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
if (method !== "tools/call") {
|
|
646
|
+
if (child) forward(msg)
|
|
647
|
+
else replyError(id, -32601, "Method not found")
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
await ensureChild()
|
|
652
|
+
} catch (err) {
|
|
653
|
+
// A tool that is LISTED but dies when called is worse than one that was never offered, so say
|
|
654
|
+
// exactly what went wrong in the tool result the MODEL reads, not only in a protocol error it
|
|
655
|
+
// never sees.
|
|
656
|
+
reply(id, {
|
|
657
|
+
content: [{ type: "text", text: `frizz could not start ${spec.name}@${spec.version}: ${errText(err)}` }],
|
|
658
|
+
isError: true,
|
|
659
|
+
})
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
forward(msg)
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** @param {any} msg */
|
|
668
|
+
function forward(msg) {
|
|
669
|
+
if (msg.id !== undefined && msg.id !== null) inFlight.add(msg.id)
|
|
670
|
+
sendChild(msg)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---- lifecycle -----------------------------------------------------------------------------------
|
|
674
|
+
function shutdown(code) {
|
|
675
|
+
try {
|
|
676
|
+
child?.kill()
|
|
677
|
+
} catch {
|
|
678
|
+
/* already gone */
|
|
679
|
+
}
|
|
680
|
+
process.exit(code)
|
|
681
|
+
}
|
|
682
|
+
process.on("exit", () => {
|
|
683
|
+
try {
|
|
684
|
+
child?.kill()
|
|
685
|
+
} catch {
|
|
686
|
+
/* already gone */
|
|
687
|
+
}
|
|
688
|
+
for (const path of ownStaging) {
|
|
689
|
+
try {
|
|
690
|
+
rmSync(path, { recursive: true, force: true })
|
|
691
|
+
} catch {
|
|
692
|
+
/* best effort on the way out */
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
})
|
|
696
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => shutdown(0))
|
|
697
|
+
|
|
698
|
+
if (HARVEST) {
|
|
699
|
+
harvest().then(
|
|
700
|
+
(result) => {
|
|
701
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n")
|
|
702
|
+
shutdown(0)
|
|
703
|
+
},
|
|
704
|
+
(err) => {
|
|
705
|
+
note(`harvest failed: ${errText(err)}`)
|
|
706
|
+
shutdown(1)
|
|
707
|
+
},
|
|
708
|
+
)
|
|
709
|
+
} else {
|
|
710
|
+
// NDJSON reader: buffer stdin, dispatch each complete line. Messages never contain raw newlines.
|
|
711
|
+
let buf = ""
|
|
712
|
+
process.stdin.setEncoding("utf8")
|
|
713
|
+
process.stdin.on("data", (chunk) => {
|
|
714
|
+
buf += chunk
|
|
715
|
+
let nl
|
|
716
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
717
|
+
const line = buf.slice(0, nl).trim()
|
|
718
|
+
buf = buf.slice(nl + 1)
|
|
719
|
+
if (!line) continue
|
|
720
|
+
let msg
|
|
721
|
+
try {
|
|
722
|
+
msg = JSON.parse(line)
|
|
723
|
+
} catch {
|
|
724
|
+
continue // ignore unparseable lines
|
|
725
|
+
}
|
|
726
|
+
void handle(msg)
|
|
727
|
+
}
|
|
728
|
+
})
|
|
729
|
+
process.stdin.on("end", () => shutdown(0))
|
|
730
|
+
}
|