diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5007d..ae218d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.1.61] - 17-07-2026 + +### Added + +- **Structured tool output** — every one of the 50 MCP tools now declares a zod `outputSchema`, and `structuredContent` is emitted on all return paths, including `browser_screenshot`'s 4 branches (new `screenshot-result.ts` helper). Downstream clients can now consume typed results instead of parsing text. +- **`browser_snapshot` `prune` option** — opt-in (default `false`, behavior unchanged) param that elides hidden elements (`aria-hidden`, `display:none`, `visibility:hidden`) from the accessibility tree, shrinking snapshots on dense pages. + +### Changed + +- MCP text `content` is now compact (non-indented) JSON — `structuredContent` unchanged — cutting tokens on every tool response with no semantic difference. +- Dependencies bumped to latest: `typescript` 6 → 7, `patchright`/`playwright` 1.60 → 1.61.1, `defuddle` 0.18 → 0.19, `@biomejs/biome` 2.4 → 2.5.4, plus patch bumps for `impit`/`libphonenumber-js`/`linkedom`/`tsx`. `@types/node` → `^22` and `engines.node` → `">=22"` (Node 20 is EOL 2026-04-30). + +### Verified + +- New unit coverage: batched `outputSchema` parsing across tools, `prune`, and all 4 `browser_screenshot` branches. New integration coverage: `browser_snapshot`/`browser_act` `outputSchema` + `prune` on real Chromium. + ## [0.1.60] - 01-07-2026 ### Added diff --git a/README.md b/README.md index f72bdb9..05f8a00 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shadow DOM + iframes), multi-step plans, structured extraction, visual diff, and guardrails** for payments and bookings. It drives real Chromium, so it reads **Next.js / SPA** pages after hydration — not just static HTML. -> 50 MCP tools · stealth + rotating proxies · HTTP fast-path (single, batch & crawl) · full-site content + screenshot snapshots · structured per-card product extraction · form fill + file upload · encrypted credential vault + TOTP (fill-by-reference, origin-bound, never exposed to the LLM) · hover + drag&drop · PDF export · cookies / permissions · network mocking · clipboard · virtualized-list scraping + autoscroll · tabs / dialogs / downloads · console + network logs · MCP screenshot resources · `FUSE_CAPS` tool-group filtering · named auth profiles (cookies + localStorage + IndexedDB, saved at login) · `blockResources` · HAR record/replay · pixel visual-diff · human handoff + live view. +> 50 MCP tools · stealth + rotating proxies · HTTP fast-path (single, batch & crawl) · full-site content + screenshot snapshots · structured per-card product extraction · form fill + file upload · encrypted credential vault + TOTP (fill-by-reference, origin-bound, never exposed to the LLM) · hover + drag&drop · PDF export · cookies / permissions · network mocking · clipboard · virtualized-list scraping + autoscroll · tabs / dialogs / downloads · console + network logs · MCP screenshot resources · `FUSE_CAPS` tool-group filtering · named auth profiles (cookies + localStorage + IndexedDB, saved at login) · `blockResources` · HAR record/replay · pixel visual-diff · human handoff + live view · typed structured output (`outputSchema` + `structuredContent`). ## Install diff --git a/bun.lock b/bun.lock index b705da0..ba1afcd 100644 --- a/bun.lock +++ b/bun.lock @@ -6,21 +6,21 @@ "name": "fuse-browser", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "defuddle": "^0.18.1", + "defuddle": "^0.19.1", "fast-png": "^8.0.0", - "impit": "^0.14.1", - "libphonenumber-js": "^1.13.5", - "linkedom": "^0.18.12", - "patchright": "^1.60.1", + "impit": "^0.14.3", + "libphonenumber-js": "^1.13.9", + "linkedom": "^0.18.13", + "patchright": "^1.61.1", "pixelmatch": "^7.2.0", - "playwright": "^1.60.0", + "playwright": "^1.61.1", "zod": "^4.4.3", }, "devDependencies": { - "@biomejs/biome": "^2.4.16", - "@types/node": "^25.9.1", - "tsx": "^4.22.4", - "typescript": "^6.0.3", + "@biomejs/biome": "^2.5.4", + "@types/node": "^22.10.5", + "tsx": "^4.23.1", + "typescript": "^7.0.2", }, }, }, @@ -29,23 +29,23 @@ "playwright", ], "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], @@ -105,9 +105,49 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -117,7 +157,7 @@ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -139,25 +179,25 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + "css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="], - "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="], "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "defuddle": ["defuddle@0.18.1", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "linkedom": "^0.18.12", "mathml-to-latex": "^1.5.0", "temml": "^0.13.1", "turndown": "^7.2.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-AvFPFOsoDjt5xUOA1QxzafSSzJ5dqEIC63yO72tHYtSjj1DYY/XM0XTPUCsHkm5A2f1X9ulBvoSVFJrd4s2ckA=="], + "defuddle": ["defuddle@0.19.1", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "linkedom": "^0.18.12", "mathml-to-latex": "^1.8.0", "temml": "^0.13.3", "turndown": "^7.2.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-7e2IVQYuNncMe9Ws8KkU/KHD8H1LFfFPmdTgRVuQNgJPOeQQSqZzAhacCyNAGwg44cM2vwuCW1cy9fmbdOZ+pA=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], - "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], - "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -225,23 +265,23 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "impit": ["impit@0.14.1", "", { "optionalDependencies": { "impit-darwin-arm64": "0.14.1", "impit-darwin-x64": "0.14.1", "impit-linux-arm64-gnu": "0.14.1", "impit-linux-arm64-musl": "0.14.1", "impit-linux-x64-gnu": "0.14.1", "impit-linux-x64-musl": "0.14.1", "impit-win32-arm64-msvc": "0.14.1", "impit-win32-x64-msvc": "0.14.1" } }, "sha512-ooFuGjrJzV16S5y4t7ICpfaAapNRrrUCv3cd5xIPKUqbbgKtx7CbVB+w+YRDMntoHwjuZ9ijI2AM8hMKISbvcw=="], + "impit": ["impit@0.14.3", "", { "optionalDependencies": { "impit-darwin-arm64": "0.14.3", "impit-darwin-x64": "0.14.3", "impit-linux-arm64-gnu": "0.14.3", "impit-linux-arm64-musl": "0.14.3", "impit-linux-x64-gnu": "0.14.3", "impit-linux-x64-musl": "0.14.3", "impit-win32-arm64-msvc": "0.14.3", "impit-win32-x64-msvc": "0.14.3" } }, "sha512-SbCLoeW0YDRox5kQoy71jtpjZE+BwjWO411OdgfbLMbNmSX79s5Y0Y2vCE7AO6zQSq9F4Fu8SJTCKnp8rXQzpg=="], - "impit-darwin-arm64": ["impit-darwin-arm64@0.14.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K3KXto3gzR60kOFFxRmuxPNl3sPhdTLLpjBIW3RKD6vJFiGV5YH/T218WBECygPxRaIhESfsxfcOYrP53bHgFw=="], + "impit-darwin-arm64": ["impit-darwin-arm64@0.14.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kMoQB+CR+a954pnhe4kf1O2RyJCsqGRE95jIXfgqNOiIMS5O1z0cOMzG/sohJk/nodZVSJ5VdWEV4BKhRWPFSA=="], - "impit-darwin-x64": ["impit-darwin-x64@0.14.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k+w8SBHUtLX3KbzsCqSmcwitHfbOXXsLryL++KVahtFqU5D93T1buoTANnd63/mm5dNyadecIGG0MQ7SwvWx8g=="], + "impit-darwin-x64": ["impit-darwin-x64@0.14.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft9+kjz1pR8H5xbbf5U1EgwaaIea5iXHxBf2Q3NoinPpiV7KgyzYSr83pCSrAY6evWk+smG9shHgzhQtgbj1Rg=="], - "impit-linux-arm64-gnu": ["impit-linux-arm64-gnu@0.14.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yBT7h6cVHL/hefzI/qjJc22fKP386O0inReOpUzCnb4SGnwhEHvL9nJk3OzU2prO9vvKNKSb6vfRBcvQ8HHH1g=="], + "impit-linux-arm64-gnu": ["impit-linux-arm64-gnu@0.14.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-+mwta93S6Ndfpca3DDblvrqV1g8Bg6gZFOlEiJHfGJZpiUIQE+A/Pjg2e+inV+WrTtlnLsvff8lgwFvkIGnRug=="], - "impit-linux-arm64-musl": ["impit-linux-arm64-musl@0.14.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-lFatihYntVIN814AnoNm/hSw5iONLKt6XtaAaJahZNdJOU+zsclcg9pbK0/61aENamkpIBpok/H8j5Sw/Xjuuw=="], + "impit-linux-arm64-musl": ["impit-linux-arm64-musl@0.14.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-wYhfH1J8laWkpSN2SAFCKx9npY4vXrk23ex+tOzTGf2xBBqIhpMUff2wa6dOFb0or6EhZHBssoRf0HK3h/htXQ=="], - "impit-linux-x64-gnu": ["impit-linux-x64-gnu@0.14.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oioVQzHqSi68BzxRpVLbNv9HRgOxAA21QNlzCMyeEM3/c02MkURCQ1W66yAlEFOR1gaqPZDJi/ymnmIGk6+PjQ=="], + "impit-linux-x64-gnu": ["impit-linux-x64-gnu@0.14.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hAYnutJDGQO5bPvTPKBBFkHgbB7QL7QLDse8c4s21VSzC/EqGliSS3UqR5ZUUwaFdm3t8DJsXOr+gboAUwDR7w=="], - "impit-linux-x64-musl": ["impit-linux-x64-musl@0.14.1", "", { "os": "linux", "cpu": "x64" }, "sha512-v7fBi405zZepQo0LqH51H+aXc/MYEtrQviIK+34kEqFBYOaSC9+wFZrs6A4WwrF9DaUuRZ3klisljwglN7rxDQ=="], + "impit-linux-x64-musl": ["impit-linux-x64-musl@0.14.3", "", { "os": "linux", "cpu": "x64" }, "sha512-wXBeHiqCjuyqxg4u5eY4OBNVzsS4Karz/ms/0mjB8aqVZLNER1NaUTeE4J3eZCdytFaJRaAeQvKTH8HfjpPsQg=="], - "impit-win32-arm64-msvc": ["impit-win32-arm64-msvc@0.14.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-W4kPvvra8dXDdtJQbkxWPCCouUlZpOrLqHsJ7gc8CwAr69ljZqw6v+qB395nCD1rn3u32dV4d4fjBsyMdolQoA=="], + "impit-win32-arm64-msvc": ["impit-win32-arm64-msvc@0.14.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-XiF8MYpm4tF8TMbtsWrcEDDs6NxRNeirfiHM/AyKcr2jiN34hZwbZDM0EseD30PDwb2Pny2JWB+zTxpE1P5CZg=="], - "impit-win32-x64-msvc": ["impit-win32-x64-msvc@0.14.1", "", { "os": "win32", "cpu": "x64" }, "sha512-gwzWsaORyzq294t8CCxfHAmqulnQNQZK8ad8YM6oQaGVXeUFa8r/ZnR+PglfYcrYF/GbYwGrf9U8StUU8KFLmQ=="], + "impit-win32-x64-msvc": ["impit-win32-x64-msvc@0.14.3", "", { "os": "win32", "cpu": "x64" }, "sha512-HIaLSpU5SGVx3UDH/R3ZaGMURVgyRVjmalqXj65ABxVUqfXiBo1t5ZObDPPjqvLT3klPnrya1+xF1DQ4dfT+og=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -261,13 +301,13 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "libphonenumber-js": ["libphonenumber-js@1.13.5", "", {}, "sha512-7/kRezHmQlMfO6pmvt34orO/g3j1C47k8FCBXFgj/mklTLwQdBca1LkhDK6RM8UyM6JqHFAIikMdkKkyfQy39A=="], + "libphonenumber-js": ["libphonenumber-js@1.13.9", "", {}, "sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ=="], - "linkedom": ["linkedom@0.18.12", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q=="], + "linkedom": ["linkedom@0.18.13", "", { "dependencies": { "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mathml-to-latex": ["mathml-to-latex@1.5.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.10" } }, "sha512-rrWn0eEvcEcdMM4xfHcSGIy+i01DX9byOdXTLWg+w1iJ6O6ohP5UXY1dVzNUZLhzfl3EGcRekWLhY7JT5Omaew=="], + "mathml-to-latex": ["mathml-to-latex@1.8.0", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10" } }, "sha512-gQ0uK3zqB8HwlfaXJkEL5rgaZNbKUiBMmBP/B/W+b+t6KcseLSuYb1b0BjLgS9ZiQa24ePkqTX8/6FaQuDL7wQ=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -281,7 +321,7 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -293,9 +333,9 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "patchright": ["patchright@1.60.1", "", { "dependencies": { "patchright-core": "1.60.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "patchright": "cli.js" } }, "sha512-9T4bWxQ9gwduSPflE5eAAiGHxjMRio8189oxxGfW3OFxhSGOcvLP8xGrsW2ZHkn8aSUqEb33cr74rV12pvwNgg=="], + "patchright": ["patchright@1.61.1", "", { "dependencies": { "patchright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "patchright": "cli.js" } }, "sha512-kZWNZ2tunsBMTBWtARFVWaNvB739GOybTlIPLT91eyx0YERjtUjESawomwO0hnOh6jt1L3Sru0PK62ylYOfqQw=="], - "patchright-core": ["patchright-core@1.60.1", "", { "bin": { "patchright-core": "cli.js" } }, "sha512-CJ6ulXQkis3MfrA379i6Nw8Xf7BPj8/WbQbk7imcZN7T0NOazWieagPUhC2s6MsUVKpvVSVRHCS52PrX3wDtUg=="], + "patchright-core": ["patchright-core@1.61.1", "", { "bin": { "patchright-core": "cli.js" } }, "sha512-d6Ju67DE6OzqlEX3WPvUX2SLEs6iMY1WERit//mpsrT98VHcztaUo0CBw2Lpxq25VdufVEiWEdcc0HnLwdiq6A=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -305,9 +345,9 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], - "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], @@ -349,17 +389,17 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "tsx": ["tsx@4.23.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="], "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -373,12 +413,26 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "dom-serializer/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "dom-serializer/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + + "domhandler/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "domutils/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "htmlparser2/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], "patchright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "htmlparser2/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "htmlparser2/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], } } diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 1b887c8..155163a 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -12,6 +12,10 @@ Every field is optional unless **Required** says `yes`. Defaults shown below com The shared identity/profile options (the `agentOptionShape`) are listed once under [`browser_open`](#browser_open); tools that accept them say so and link back. +## Return format (structured output) + +Every tool declares an `outputSchema` (zod) and returns a **typed `structuredContent`** validated against it, alongside the human-readable `content`. Clients that support MCP structured output read `structuredContent` directly (typed, no parsing); clients that don't fall back to the `content` block. The `content` text is **compact JSON** (non-indented) — the pretty-print whitespace only inflated the token cost, and it stays semantically identical to `structuredContent`. Image tools (`browser_screenshot`, and any tool called with `annotate:true`) return the image in `content` **plus** the structured metadata in `structuredContent`. Error results set `isError` and, when a machine-readable code applies, expose `structuredContent: { code, message }`. + ## Capability groups (`FUSE_CAPS`) By default all 50 tools are registered. Set the `FUSE_CAPS` env var (comma-separated group names) to expose fewer tools — a lighter context for the LLM client: @@ -543,12 +547,17 @@ Return the indexed interactive elements of the live page, each with a `ref` to u | --- | --- | --- | --- | | `sessionId` | string | yes | Target session. | | `selectors` | boolean | no | Also return a durable CSS `selector` per element (cacheable to act later without re-snapshotting). | +| `prune` | boolean | no | Drop elements hidden for accessibility (default `false`, output unchanged). When `true`, prunes any element that is `aria-hidden` (self or ancestor), `display:none` (self or ancestor), or `visibility:hidden`/`collapse` — **never** on off-screen, `opacity:0`, or off-viewport position. | | `annotate` | boolean | no | Also return a Set-of-Marks JPEG: numbered badges (= each `ref`) drawn over the page, for vision models (main-frame, viewport-only). | ```json { "sessionId": "s_abc123", "annotate": true } ``` +```json +{ "sessionId": "s_abc123", "prune": true } +``` + ### browser_act Execute click/fill/select/pick/upload/hover/drag on an element by `ref` (from `browser_snapshot`) or by `target` text. Returns a diff of what changed on the page (added/removed/text/url). diff --git a/package.json b/package.json index 930233a..e722b75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/browser-mcp", - "version": "0.1.60", + "version": "0.1.61", "description": "MCP server + CLI giving AI agents a real, stealth browser (Patchright/Playwright) — per-country identity, self-healing actions, snapshots, multi-step plans, structured extraction, CDP attach.", "license": "MIT", "author": "Fusengine", @@ -29,7 +29,7 @@ "access": "public" }, "engines": { - "node": ">=20" + "node": ">=22" }, "bin": { "fuse-browser": "dist/bin/cli.js", @@ -66,20 +66,20 @@ ], "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "defuddle": "^0.18.1", + "defuddle": "^0.19.1", "fast-png": "^8.0.0", - "impit": "^0.14.1", - "libphonenumber-js": "^1.13.5", - "linkedom": "^0.18.12", - "patchright": "^1.60.1", + "impit": "^0.14.3", + "libphonenumber-js": "^1.13.9", + "linkedom": "^0.18.13", + "patchright": "^1.61.1", "pixelmatch": "^7.2.0", - "playwright": "^1.60.0", + "playwright": "^1.61.1", "zod": "^4.4.3" }, "devDependencies": { - "@biomejs/biome": "^2.4.16", - "@types/node": "^25.9.1", - "tsx": "^4.22.4", - "typescript": "^6.0.3" + "@biomejs/biome": "^2.5.4", + "@types/node": "^22.10.5", + "tsx": "^4.23.1", + "typescript": "^7.0.2" } } diff --git a/src/extraction/snapshot-hidden.ts b/src/extraction/snapshot-hidden.ts new file mode 100644 index 0000000..4cdfc21 --- /dev/null +++ b/src/extraction/snapshot-hidden.ts @@ -0,0 +1,66 @@ +/** + * "Hidden for accessibility" detection, mirroring Playwright's + * `isElementHiddenForAria` (packages/injected/src/roleUtils.ts): `aria-hidden` + * and `display:none` are STICKY down the ancestor chain (a descendant cannot + * un-hide itself); `visibility:hidden|collapse` is checked only on the element + * itself (CSS `visibility` inherits, so a descendant's `visibility:visible` + * already resolves through `getComputedStyle`, no manual override needed). + * `display:contents` never counts as `display:none`, so it does not blanket- + * hide the subtree. Deliberately NOT signals here: offscreen position + * (`offsetParent===null`, e.g. `position:fixed`), `opacity:0`, or `sr-only` + * absolute-offscreen patterns — none of those are ARIA-hidden. + * @module extraction/snapshot-hidden + */ + +/** + * Browser-side definition injected into {@link SNAPSHOT_SCRIPT}: defines + * `isElementHiddenForAria(el)`, embedded the same way `selector.ts` embeds + * `SELECTOR_DEFS`. + */ +export const HIDDEN_DEFS = ` + const ariaHiddenAncestor = (el) => { + let node = el; + while (node && node.nodeType === 1) { + if (node.getAttribute('aria-hidden') === 'true') return true; + if (getComputedStyle(node).display === 'none') return true; + node = node.parentElement || (node.parentNode && node.parentNode.host); + } + return false; + }; + const isElementHiddenForAria = (el) => { + const v = getComputedStyle(el).visibility; + if (v === 'hidden' || v === 'collapse') return true; + return ariaHiddenAncestor(el); + };`; + +/** + * Minimal ancestor-chain shape mirroring the DOM API surface used by + * {@link HIDDEN_DEFS} above, so the same decision logic is unit-testable + * without a real DOM (same intent as `selector.ts`'s `isStableToken`). + */ +export interface AriaHiddenNode { + /** This node's own `aria-hidden` attribute value, or `null`/absent. */ + ariaHidden?: string | null; + /** This node's own computed `display`. */ + display?: string; + /** This node's own (already-inherited) computed `visibility`. */ + visibility?: string; + /** Parent in the ancestor chain (host element, for a shadow-root parent). */ + parent?: AriaHiddenNode | null; +} + +/** + * Node-testable mirror of the browser-side `isElementHiddenForAria`: true if + * the node's own resolved `visibility` is hidden/collapse, OR if `aria-hidden` + * or `display:none` appears on the node or any ancestor. + */ +export function isHiddenForAriaMirror(node: AriaHiddenNode): boolean { + if (node.visibility === "hidden" || node.visibility === "collapse") return true; + let cur: AriaHiddenNode | null | undefined = node; + while (cur) { + if (cur.ariaHidden === "true") return true; + if (cur.display === "none") return true; + cur = cur.parent; + } + return false; +} diff --git a/src/extraction/snapshot-walk.ts b/src/extraction/snapshot-walk.ts index 0b40035..1851829 100644 --- a/src/extraction/snapshot-walk.ts +++ b/src/extraction/snapshot-walk.ts @@ -7,6 +7,7 @@ * @module extraction/snapshot-walk */ +import { HIDDEN_DEFS } from "./snapshot-hidden.js"; import { SELECTOR_DEFS } from "./selector.js"; /** Attribute injected on each interactive element to anchor a stable ref. */ @@ -23,7 +24,7 @@ const SELECTOR = */ export const SNAPSHOT_SCRIPT = `(arg) => { const SEL = '${SELECTOR}'; - const wantSel = !!(arg && arg.selectors);${SELECTOR_DEFS} + const wantSel = !!(arg && arg.selectors);${SELECTOR_DEFS}${HIDDEN_DEFS} const obscured = (el, r) => { const cx = r.x + r.width / 2, cy = r.y + r.height / 2; if (r.width === 0 || cx < 0 || cy < 0 || cx > innerWidth || cy > innerHeight) return false; @@ -48,6 +49,7 @@ export const SNAPSHOT_SCRIPT = `(arg) => { options: el.tagName === 'SELECT' ? [...el.options].slice(0, 12).map((o) => o.label || o.value) : undefined, ariaExpanded: el.getAttribute('aria-expanded'), ariaControls: el.getAttribute('aria-controls'), visible: r.width > 0 && r.height > 0, obscured: obscured(el, r), + ariaHidden: isElementHiddenForAria(el), selector: wantSel ? genSelector(el) : undefined, box: {x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height)} }; diff --git a/src/extraction/snapshot.ts b/src/extraction/snapshot.ts index a087be3..4fdbd1b 100644 --- a/src/extraction/snapshot.ts +++ b/src/extraction/snapshot.ts @@ -17,13 +17,35 @@ export { REF_ATTRIBUTE } from "./snapshot-walk.js"; /** Soft cap on total elements across all frames, to bound output size. */ const MAX_ELEMENTS = 400; +/** + * Browser-returned element plus the internal `ariaHidden` scratch flag (see + * `snapshot-hidden.ts`). Never exposed on the final {@link InteractiveElement} + * output — used only to decide pruning, then stripped in {@link captureSnapshot}. + */ +type RawElement = InteractiveElement & { ariaHidden?: boolean }; + +/** + * Whether a raw element survives pruning: kept unless `prune` is on AND the + * element was flagged hidden-for-accessibility. Exported for unit testing. + */ +export function shouldKeep(ariaHidden: boolean | undefined, prune: boolean): boolean { + return !(prune && ariaHidden === true); +} + /** * Capture the indexed interactive snapshot across all frames, tagging each * element with a (frame-local) ref attribute and exposing a frame-scoped `ref`. * Detached frames and frames that reject evaluation (e.g. mid-navigation) are - * skipped rather than aborting the whole snapshot. + * skipped rather than aborting the whole snapshot. When `prune` is `true`, + * elements hidden for accessibility (`aria-hidden`, `display:none`, ancestor- + * hidden, or `visibility:hidden`/`collapse`) are dropped; default `false` + * keeps the output identical to the pre-pruning behavior. */ -export async function captureSnapshot(page: Page, selectors = false): Promise { +export async function captureSnapshot( + page: Page, + selectors = false, + prune = false, +): Promise { const frames = page.frames(); const all: InteractiveElement[] = []; const arg = { selectors }; @@ -31,14 +53,16 @@ export async function captureSnapshot(page: Page, selectors = false): Promise(frame, SNAPSHOT_SCRIPT, arg); + local = await evalScriptArg(frame, SNAPSHOT_SCRIPT, arg); } catch { continue; } - for (const el of local) { + for (const raw of local) { if (all.length >= MAX_ELEMENTS) break; + const { ariaHidden, ...el } = raw; + if (!shouldKeep(ariaHidden, prune)) continue; el.ref = f === 0 ? String(el.index) : `${f}:${el.index}`; if (f > 0) el.frame = f; el.index = global++; diff --git a/src/server/resources.ts b/src/server/resources.ts index 3e1298b..d36c3c8 100644 --- a/src/server/resources.ts +++ b/src/server/resources.ts @@ -34,7 +34,7 @@ function registerRunsIndex(server: McpServer): void { }, async (uri) => ({ contents: [ - { uri: uri.href, mimeType: "application/json", text: JSON.stringify(listRuns(), null, 2) }, + { uri: uri.href, mimeType: "application/json", text: JSON.stringify(listRuns()) }, ], }), ); diff --git a/src/server/result.ts b/src/server/result.ts index ce7bd6f..15f34ed 100644 --- a/src/server/result.ts +++ b/src/server/result.ts @@ -4,10 +4,15 @@ */ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -/** Text + structured JSON result. */ +/** + * Text + structured JSON result. The text block carries compact (non-indented) + * JSON: the pretty-print whitespace only inflated the wire/token cost with no + * gain for the consuming model, which reads the same data from + * `structuredContent` anyway. The two stay semantically equivalent (MCP spec). + */ export function jsonResult(payload: Record): CallToolResult { return { - content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + content: [{ type: "text", text: JSON.stringify(payload) }], structuredContent: payload, }; } @@ -21,7 +26,7 @@ export function imageJsonResult( return { content: [ { type: "image", data: base64, mimeType }, - { type: "text", text: JSON.stringify(payload, null, 2) }, + { type: "text", text: JSON.stringify(payload) }, ], structuredContent: payload, }; diff --git a/src/server/tools/act.ts b/src/server/tools/act.ts index 03bcd90..b0505b0 100644 --- a/src/server/tools/act.ts +++ b/src/server/tools/act.ts @@ -17,6 +17,25 @@ import { withSession } from "./with-session.js"; type Shape = Record; type Build = (a: Record) => ActionInput; +/** + * `ActionResult` (interfaces/types.ts) has an index signature — model extra + * keys permissively. Shared with wait.ts (`waitForCondition` also returns an + * `ActionResult`, tagged `wait_for` with extra `condition`/`value` fields). + */ +export const actionResultShape = z + .object({ + type: z.string(), + ok: z.boolean(), + target: z.string().optional(), + strategy: z.string().optional(), + error: z.string().optional(), + ms: z.number().optional(), + }) + .catchall(z.unknown()); + +/** Every `actTool` handler returns `{ result, url }` via `jsonResult`. */ +const actOutputShape = { result: actionResultShape, url: z.string() }; + function actTool( server: McpServer, sessions: SessionManager, @@ -25,7 +44,7 @@ function actTool( inputSchema: Shape, build: Build, ): void { - server.registerTool(name, { title: name, description, inputSchema }, async (args) => { + server.registerTool(name, { title: name, description, inputSchema, outputSchema: actOutputShape }, async (args) => { const a = args as Record; return withSession(sessions, String(a.sessionId), async (s) => { const action = build(a); diff --git a/src/server/tools/autoscroll.ts b/src/server/tools/autoscroll.ts index 6c681d1..2248f21 100644 --- a/src/server/tools/autoscroll.ts +++ b/src/server/tools/autoscroll.ts @@ -10,6 +10,8 @@ import type { SessionManager } from "../../session/manager.js"; import { jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; +const autoScrollOutputShape = { rounds: z.number(), height: z.number(), url: z.string() }; + /** Register `browser_autoscroll`. */ export function registerAutoScrollTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -26,6 +28,7 @@ export function registerAutoScrollTool(server: McpServer, sessions: SessionManag minCount: z.number().int().positive().optional(), delayMs: z.number().int().nonnegative().optional(), }, + outputSchema: autoScrollOutputShape, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/clipboard.ts b/src/server/tools/clipboard.ts index 34323f3..4648593 100644 --- a/src/server/tools/clipboard.ts +++ b/src/server/tools/clipboard.ts @@ -48,6 +48,12 @@ export async function applyClipboard( return evalScript(page, READ_SCRIPT); } +/** Merged success shape across the 2 actions (read/write). */ +export const CLIPBOARD_OUTPUT_SHAPE = { + written: z.literal(true).optional(), + text: z.string().optional(), +}; + /** Register `browser_clipboard`. */ export function registerClipboardTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -60,6 +66,7 @@ export function registerClipboardTool(server: McpServer, sessions: SessionManage action: z.enum(["read", "write"]), text: z.string().optional(), }, + outputSchema: CLIPBOARD_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/collect-batch.ts b/src/server/tools/collect-batch.ts index 4066286..c5b55a0 100644 --- a/src/server/tools/collect-batch.ts +++ b/src/server/tools/collect-batch.ts @@ -12,6 +12,25 @@ import { resolveConfig } from "../../agent/config.js"; import { toAgentOptions } from "../map-options.js"; import { progressReporter } from "../progress.js"; import { jsonResult } from "../result.js"; +import { urlErrorSchema } from "./schemas-fetch-output.js"; +import { collectedItemSchema } from "./schemas-price-output.js"; + +/** `browser_collect_batch` output shape: one collected list or `{url,error}` per input URL. */ +export const COLLECT_BATCH_OUTPUT_SHAPE = { + count: z.number(), + results: z.array( + z.union([ + z.object({ + url: z.string(), + count: z.number(), + steps: z.number(), + reachedEnd: z.boolean(), + items: z.array(collectedItemSchema), + }), + urlErrorSchema, + ]), + ), +}; /** Register `browser_collect_batch`. */ export function registerCollectBatchTool(server: McpServer): void { @@ -34,6 +53,7 @@ export function registerCollectBatchTool(server: McpServer): void { headless: z.boolean().optional(), proxyUrl: z.string().optional(), }, + outputSchema: COLLECT_BATCH_OUTPUT_SHAPE, }, async (args, extra) => { const a = args as Record; diff --git a/src/server/tools/collect-schema.ts b/src/server/tools/collect-schema.ts new file mode 100644 index 0000000..e53447f --- /dev/null +++ b/src/server/tools/collect-schema.ts @@ -0,0 +1,45 @@ +/** + * Zod input/output schemas for `browser_collect` — the declarative + * clean→validate→dedupe→emit pipeline spec (input) and the tool's result + * shape (output). Split out of `collect.ts` to keep it under the file-size + * budget. + * @module server/tools/collect-schema + */ +import { z } from "zod"; + +const fieldRule = z.object({ + required: z.boolean().optional(), + type: z.enum(["string", "number", "boolean"]).optional(), + regex: z.string().optional(), + min: z.number().optional(), + max: z.number().optional(), + enum: z.array(z.unknown()).optional(), +}); + +/** Declarative clean→validate→dedupe→emit spec applied to collected rows. */ +export const pipelineInputSchema = z + .object({ + clean: z.object({ numericFields: z.array(z.string()).optional() }).optional(), + validate: z.record(z.string(), fieldRule).optional(), + dedupeBy: z.array(z.string()).optional(), + keep: z.enum(["first", "last"]).optional(), + columns: z.array(z.string()).optional(), + emit: z.enum(["json", "csv"]).optional(), + }) + .optional(); + +/** + * `browser_collect` output shape. `items`/`csv` stay permissive: with + * `pipeline.columns` set, rows are reshaped to an arbitrary caller-chosen + * column set (see `runPipeline`), so a precise per-field schema would throw + * on a valid, differently shaped payload. Top-level keys stay optional since + * the two return branches (plain vs pipeline) populate a different subset. + */ +export const collectOutputShape = { + count: z.number(), + steps: z.number(), + reachedEnd: z.boolean(), + invalidCount: z.number().optional(), + items: z.array(z.record(z.string(), z.unknown())), + csv: z.string().optional(), +}; diff --git a/src/server/tools/collect.ts b/src/server/tools/collect.ts index 0e1f6c7..f9cb886 100644 --- a/src/server/tools/collect.ts +++ b/src/server/tools/collect.ts @@ -13,29 +13,9 @@ import type { PipelineSpec } from "../../interfaces/pipeline.js"; import { scrollCollect } from "../../state/scroll-collect.js"; import type { SessionManager } from "../../session/manager.js"; import { jsonResult } from "../result.js"; +import { collectOutputShape, pipelineInputSchema } from "./collect-schema.js"; import { withSession } from "./with-session.js"; -const FIELD_RULE = z.object({ - required: z.boolean().optional(), - type: z.enum(["string", "number", "boolean"]).optional(), - regex: z.string().optional(), - min: z.number().optional(), - max: z.number().optional(), - enum: z.array(z.unknown()).optional(), -}); - -/** Declarative clean→validate→dedupe→emit spec applied to collected rows. */ -const PIPELINE_SCHEMA = z - .object({ - clean: z.object({ numericFields: z.array(z.string()).optional() }).optional(), - validate: z.record(z.string(), FIELD_RULE).optional(), - dedupeBy: z.array(z.string()).optional(), - keep: z.enum(["first", "last"]).optional(), - columns: z.array(z.string()).optional(), - emit: z.enum(["json", "csv"]).optional(), - }) - .optional(); - /** Register `browser_collect`. */ export function registerCollectTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -50,8 +30,9 @@ export function registerCollectTool(server: McpServer, sessions: SessionManager) container: z.string().optional(), maxSteps: z.number().int().optional(), extractPrices: z.boolean().optional(), - pipeline: PIPELINE_SCHEMA, + pipeline: pipelineInputSchema, }, + outputSchema: collectOutputShape, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/connect.ts b/src/server/tools/connect.ts index 1c708f3..246cd4c 100644 --- a/src/server/tools/connect.ts +++ b/src/server/tools/connect.ts @@ -12,6 +12,13 @@ import { errorResult, jsonResult } from "../result.js"; const BROWSERS = ["dia", "chrome", "edge", "brave", "arc"] as const; +const connectOutputShape = { + sessionId: z.string(), + endpoint: z.string(), + url: z.string(), + connected: z.boolean(), +}; + /** Register `browser_connect`. */ export function registerConnectTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -27,6 +34,7 @@ export function registerConnectTool(server: McpServer, sessions: SessionManager) userDataDir: z.string().optional(), launch: z.boolean().optional(), }, + outputSchema: connectOutputShape, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/cookies-output.ts b/src/server/tools/cookies-output.ts new file mode 100644 index 0000000..7f6d2a3 --- /dev/null +++ b/src/server/tools/cookies-output.ts @@ -0,0 +1,30 @@ +/** + * `browser_cookies` output schema — split out of `cookies.ts` to keep it under + * the 100-line SOLID limit. + * @module server/tools/cookies-output + */ +import { z } from "zod"; + +/** + * A returned Playwright cookie (`context.cookies()`). Only `name`/`value` are + * kept required — the rest are optional so the schema never rejects a lighter + * test double or a future Playwright field addition (extra keys are stripped). + */ +const COOKIE_ITEM_SHAPE = z.object({ + name: z.string(), + value: z.string(), + domain: z.string().optional(), + path: z.string().optional(), + expires: z.number().optional(), + httpOnly: z.boolean().optional(), + secure: z.boolean().optional(), + sameSite: z.enum(["Strict", "Lax", "None"]).optional(), + partitionKey: z.string().optional(), +}); + +/** Merged success shape across the 3 `browser_cookies` actions (get/set/clear). */ +export const COOKIES_OUTPUT_SHAPE = { + cookies: z.array(COOKIE_ITEM_SHAPE).optional(), + added: z.number().optional(), + cleared: z.literal(true).optional(), +}; diff --git a/src/server/tools/cookies.ts b/src/server/tools/cookies.ts index b29e26b..804cae2 100644 --- a/src/server/tools/cookies.ts +++ b/src/server/tools/cookies.ts @@ -9,8 +9,11 @@ import type { BrowserContext } from "playwright"; import { z } from "zod"; import type { SessionManager } from "../../session/manager.js"; import { errorResult, jsonResult } from "../result.js"; +import { COOKIES_OUTPUT_SHAPE } from "./cookies-output.js"; import { withSession } from "./with-session.js"; +export { COOKIES_OUTPUT_SHAPE } from "./cookies-output.js"; + /** A Playwright cookie object as accepted by `context.addCookies()`. */ type CookieInput = Parameters[0][number]; @@ -61,6 +64,7 @@ export function registerCookiesTool(server: McpServer, sessions: SessionManager) cookies: z.array(COOKIE_SCHEMA).optional(), urls: z.array(z.string()).optional(), }, + outputSchema: COOKIES_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/crawl.ts b/src/server/tools/crawl.ts index dbec0b0..af31f0b 100644 --- a/src/server/tools/crawl.ts +++ b/src/server/tools/crawl.ts @@ -9,6 +9,10 @@ import { z } from "zod"; import { crawl } from "../../agent/crawl.js"; import { progressReporter } from "../progress.js"; import { jsonResult } from "../result.js"; +import { crawlPageSchema } from "./schemas-fetch-output.js"; + +/** `browser_crawl` output shape: the discovered pages, in crawl order. */ +export const CRAWL_OUTPUT_SHAPE = { count: z.number(), pages: z.array(crawlPageSchema) }; /** Register `browser_crawl`. */ export function registerCrawlTool(server: McpServer): void { @@ -31,6 +35,7 @@ export function registerCrawlTool(server: McpServer): void { throttleMs: z.number().int().optional(), proxyUrl: z.string().optional(), }, + outputSchema: CRAWL_OUTPUT_SHAPE, }, async (args, extra) => { const a = args as Record; diff --git a/src/server/tools/dialogs.ts b/src/server/tools/dialogs.ts index a7621a3..163f88d 100644 --- a/src/server/tools/dialogs.ts +++ b/src/server/tools/dialogs.ts @@ -12,6 +12,24 @@ import type { SessionManager } from "../../session/manager.js"; import { errorResult, jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; +/** Mirrors `DialogRecord` (session/dialogs.ts). */ +const dialogRecordShape = z.object({ type: z.string(), message: z.string(), at: z.number(), handled: z.enum(["accept", "dismiss"]) }); + +const dialogOutputShape = { + policy: z.object({ action: z.enum(["accept", "dismiss"]), promptText: z.string().optional() }), + recent: z.array(dialogRecordShape), +}; + +/** Mirrors `DownloadRecord` (session/downloads.ts). */ +const downloadRecordShape = z.object({ url: z.string(), suggestedFilename: z.string(), path: z.string(), at: z.number(), error: z.string().optional() }); + +/** `content` is only present when `read` was requested (mirrors `DownloadContent`). */ +const downloadsOutputShape = { + count: z.number(), + downloads: z.array(downloadRecordShape), + content: z.object({ filename: z.string(), encoding: z.enum(["utf8", "base64"]), data: z.string() }).optional(), +}; + /** Register `browser_dialog` and `browser_downloads`. */ export function registerDialogTools(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -25,6 +43,7 @@ export function registerDialogTools(server: McpServer, sessions: SessionManager) action: z.enum(["accept", "dismiss"]), promptText: z.string().optional(), }, + outputSchema: dialogOutputShape, }, async (args) => { const a = args as Record; @@ -52,6 +71,7 @@ export function registerDialogTools(server: McpServer, sessions: SessionManager) read: z.union([z.number(), z.string()]).optional(), encoding: z.enum(["utf8", "base64"]).default("utf8"), }, + outputSchema: downloadsOutputShape, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/extract-schema.ts b/src/server/tools/extract-schema.ts index cfa394f..8e16d78 100644 --- a/src/server/tools/extract-schema.ts +++ b/src/server/tools/extract-schema.ts @@ -20,6 +20,18 @@ const fieldSpec = z.object({ abs: z.boolean().optional(), }); +/** + * `browser_extract_schema` output shape. Permissive: `data`/`items` come from + * a caller-supplied field map, so the record keys and per-field value shapes + * are arbitrary by design. + */ +export const EXTRACT_SCHEMA_OUTPUT_SHAPE = { + url: z.string(), + count: z.number().optional(), + items: z.array(z.record(z.string(), z.unknown())).optional(), + data: z.record(z.string(), z.unknown()).optional(), +}; + /** Register `browser_extract_schema`. */ export function registerExtractSchemaTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -33,6 +45,7 @@ export function registerExtractSchemaTool(server: McpServer, sessions: SessionMa schema: z.record(z.string(), fieldSpec), containerSelector: z.string().optional(), }, + outputSchema: EXTRACT_SCHEMA_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/extract.ts b/src/server/tools/extract.ts index 72b2a32..34f69df 100644 --- a/src/server/tools/extract.ts +++ b/src/server/tools/extract.ts @@ -12,10 +12,26 @@ import { extractPrices } from "../../extraction/prices.js"; import { htmlToMarkdown, renderMarkdown } from "../../extraction/serialize/to-markdown.js"; import type { SessionManager } from "../../session/manager.js"; import { jsonResult } from "../result.js"; +import { challengesSchema, hotelOffersSchema } from "./schemas-hotel-challenges-output.js"; +import { priceSchema } from "./schemas-price-output.js"; import { withSession } from "./with-session.js"; const KIND = z.enum(["text", "prices", "hotels", "challenges", "all"]); +/** + * `browser_extract` output shape. Precise, not permissive: `kind` fully + * determines which optional keys are populated (see the handler below), and + * hotelOffers/challenges' all-optional-field schemas already validate their + * `{}` empty variant. + */ +export const EXTRACT_OUTPUT_SHAPE = { + url: z.string(), + text: z.string().optional(), + prices: z.array(priceSchema).optional(), + hotelOffers: hotelOffersSchema.optional(), + challenges: challengesSchema.optional(), +}; + /** Register `browser_extract`. */ export function registerExtractTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -29,6 +45,7 @@ export function registerExtractTool(server: McpServer, sessions: SessionManager) kind: KIND.optional(), format: z.enum(["markdown", "text"]).optional(), }, + outputSchema: EXTRACT_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/fetch-batch.ts b/src/server/tools/fetch-batch.ts index de542d3..cb83d83 100644 --- a/src/server/tools/fetch-batch.ts +++ b/src/server/tools/fetch-batch.ts @@ -9,6 +9,13 @@ import { z } from "zod"; import { fetchBatch } from "../../agent/fetch-batch.js"; import { progressReporter } from "../progress.js"; import { jsonResult } from "../result.js"; +import { renderedFetchSchema, urlErrorSchema } from "./schemas-fetch-output.js"; + +/** `browser_fetch_batch` output shape: one rendered fetch or `{url,error}` per input URL. */ +export const FETCH_BATCH_OUTPUT_SHAPE = { + count: z.number(), + results: z.array(z.union([renderedFetchSchema, urlErrorSchema])), +}; /** Register `browser_fetch_batch`. */ export function registerFetchBatchTool(server: McpServer): void { @@ -26,6 +33,7 @@ export function registerFetchBatchTool(server: McpServer): void { proxyUrl: z.string().optional(), concurrency: z.number().int().optional(), }, + outputSchema: FETCH_BATCH_OUTPUT_SHAPE, }, async (args, extra) => { const a = args as Record; diff --git a/src/server/tools/fetch.ts b/src/server/tools/fetch.ts index d9582d4..472c8cb 100644 --- a/src/server/tools/fetch.ts +++ b/src/server/tools/fetch.ts @@ -12,6 +12,16 @@ import { contactsFromHtml } from "../../extraction/contacts/from-html.js"; import { extractPrices } from "../../extraction/prices.js"; import { fetchFast } from "../../net/fetch-fast.js"; import { jsonResult } from "../result.js"; +import { contactsSchema } from "./schemas-contacts-output.js"; +import { renderedFetchSchema } from "./schemas-fetch-output.js"; +import { priceSchema } from "./schemas-price-output.js"; + +/** `browser_fetch` output shape: a rendered fetch plus optional prices/contacts. */ +export const FETCH_OUTPUT_SHAPE = { + ...renderedFetchSchema.shape, + prices: z.array(priceSchema).optional(), + contacts: contactsSchema.optional(), +}; /** Register `browser_fetch`. */ export function registerFetchTool(server: McpServer): void { @@ -32,6 +42,7 @@ export function registerFetchTool(server: McpServer): void { maxChars: z.number().int().optional(), browserFallback: z.boolean().optional(), }, + outputSchema: FETCH_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/handoff.ts b/src/server/tools/handoff.ts index 15ac9c0..ef88f4e 100644 --- a/src/server/tools/handoff.ts +++ b/src/server/tools/handoff.ts @@ -12,6 +12,13 @@ import type { SessionManager } from "../../session/manager.js"; import { jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; +/** Shape of the `browser_handoff` success payload (resumed or timeout branch). */ +export const HANDOFF_OUTPUT_SHAPE = { + status: z.enum(["resumed", "timeout"]), + url: z.string(), + warning: z.string().optional(), +}; + /** Register `browser_handoff`. */ export function registerHandoffTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -27,6 +34,7 @@ export function registerHandoffTool(server: McpServer, sessions: SessionManager) selector: z.string().optional(), timeoutMs: z.number().int().optional(), }, + outputSchema: HANDOFF_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/inspect.ts b/src/server/tools/inspect.ts index fd94d0f..dbe89cb 100644 --- a/src/server/tools/inspect.ts +++ b/src/server/tools/inspect.ts @@ -11,6 +11,24 @@ import type { SessionManager } from "../../session/manager.js"; import { errorResult, jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; +/** Shape of the `browser_inspect` success payload (mirrors {@link StyleReport}). */ +export const INSPECT_OUTPUT_SHAPE = { + ref: z.string(), + box: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }), + font: z.object({ + family: z.string(), + size: z.string(), + weight: z.string(), + lineHeight: z.string(), + }), + color: z.string(), + background: z.string(), + padding: z.string(), + margin: z.string(), + border: z.string(), + contrast: z.object({ ratio: z.number(), AA: z.boolean(), AAA: z.boolean() }).nullable(), +}; + /** Register `browser_inspect`. */ export function registerInspectTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -20,6 +38,7 @@ export function registerInspectTool(server: McpServer, sessions: SessionManager) description: "Computed styles, box model and WCAG text-contrast (AA/AAA) for one element by `ref` (from browser_snapshot). For design review: typography, color, spacing, contrast. Main-frame refs.", inputSchema: { sessionId: z.string(), ref: z.union([z.number().int(), z.string()]) }, + outputSchema: INSPECT_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/live-view.ts b/src/server/tools/live-view.ts index ae7fbff..1ad73c6 100644 --- a/src/server/tools/live-view.ts +++ b/src/server/tools/live-view.ts @@ -12,6 +12,12 @@ import type { SessionManager } from "../../session/manager.js"; import { jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; +/** Shape of the `browser_live_view` success payload. */ +export const LIVE_VIEW_OUTPUT_SHAPE = { url: z.string(), note: z.string() }; + +/** Shape of the `browser_live_view_stop` success payload. */ +export const LIVE_VIEW_STOP_OUTPUT_SHAPE = { stopped: z.boolean() }; + /** Register the live-view tools. */ export function registerLiveViewTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -27,6 +33,7 @@ export function registerLiveViewTool(server: McpServer, sessions: SessionManager maxHeight: z.number().int().optional(), open: z.boolean().optional(), }, + outputSchema: LIVE_VIEW_OUTPUT_SHAPE, }, async (a) => withSession(sessions, String(a.sessionId), async (s) => { @@ -46,6 +53,7 @@ export function registerLiveViewTool(server: McpServer, sessions: SessionManager title: "Stop live view", description: "Stop the live view for a session and shut down its local server.", inputSchema: { sessionId: z.string() }, + outputSchema: LIVE_VIEW_STOP_OUTPUT_SHAPE, }, async (a) => withSession(sessions, String(a.sessionId), async (s) => { diff --git a/src/server/tools/logs-output.ts b/src/server/tools/logs-output.ts new file mode 100644 index 0000000..618d07b --- /dev/null +++ b/src/server/tools/logs-output.ts @@ -0,0 +1,29 @@ +/** + * `browser_console` / `browser_network` output schemas — split out of + * `logs.ts` to keep it under the 100-line SOLID limit. + * @module server/tools/logs-output + */ +import { z } from "zod"; + +/** One console entry (Playwright message type + text). */ +const CONSOLE_ENTRY_SHAPE = z.object({ type: z.string(), text: z.string() }); + +/** Shape of the `browser_console` success payload. */ +export const CONSOLE_OUTPUT_SHAPE = { + count: z.number(), + entries: z.array(CONSOLE_ENTRY_SHAPE), +}; + +/** One merged network row (mirrors `NetworkEntry` from `logs-filter.ts`). */ +const NETWORK_ENTRY_SHAPE = z.object({ + method: z.string().optional(), + url: z.string(), + status: z.number().optional(), + resourceType: z.string().optional(), +}); + +/** Shape of the `browser_network` success payload. */ +export const NETWORK_OUTPUT_SHAPE = { + count: z.number(), + requests: z.array(NETWORK_ENTRY_SHAPE), +}; diff --git a/src/server/tools/logs.ts b/src/server/tools/logs.ts index 65e7005..9841454 100644 --- a/src/server/tools/logs.ts +++ b/src/server/tools/logs.ts @@ -8,8 +8,11 @@ import { z } from "zod"; import type { SessionManager } from "../../session/manager.js"; import { jsonResult } from "../result.js"; import { CONSOLE_LEVELS, filterConsole, filterNetwork, mergeNetwork } from "./logs-filter.js"; +import { CONSOLE_OUTPUT_SHAPE, NETWORK_OUTPUT_SHAPE } from "./logs-output.js"; import { withSession } from "./with-session.js"; +export { CONSOLE_OUTPUT_SHAPE, NETWORK_OUTPUT_SHAPE } from "./logs-output.js"; + const CONSOLE_DESC = "Console messages captured in the session since open (last 80). Use to debug JS errors, CSP " + 'violations, or why a page misbehaves. Filter with `level` (e.g. "error"); `limit` = last N (default 50).'; @@ -31,6 +34,7 @@ export function registerLogTools(server: McpServer, sessions: SessionManager): v level: z.enum(CONSOLE_LEVELS).optional(), limit: z.number().optional(), }, + outputSchema: CONSOLE_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; @@ -55,6 +59,7 @@ export function registerLogTools(server: McpServer, sessions: SessionManager): v urlContains: z.string().optional(), limit: z.number().optional(), }, + outputSchema: NETWORK_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/metrics.ts b/src/server/tools/metrics.ts index 125d79d..385d035 100644 --- a/src/server/tools/metrics.ts +++ b/src/server/tools/metrics.ts @@ -9,6 +9,21 @@ import { z } from "zod"; import { metricsSnapshot, resetMetrics } from "../../net/metrics.js"; import { jsonResult } from "../result.js"; +/** Shape of the `browser_metrics` success payload (mirrors `metricsSnapshot()`). */ +export const METRICS_OUTPUT_SHAPE = { + uptimeMs: z.number(), + probesOk: z.number(), + probesFailed: z.number(), + avgDurationMs: z.number(), + minDurationMs: z.number(), + maxDurationMs: z.number(), + breakerRejects: z.number(), + queueRejects: z.number(), + budgetRejects: z.number(), + queue: z.object({ running: z.number(), admitted: z.number(), waiting: z.number() }), + rssBytes: z.number(), +}; + /** Register `browser_metrics`. */ export function registerMetricsTool(server: McpServer): void { server.registerTool( @@ -18,6 +33,7 @@ export function registerMetricsTool(server: McpServer): void { description: "Process-global scraping metrics: probes ok/failed, avg/min/max duration, circuit-breaker/queue/budget rejects, live queue depth, RSS, uptime. Pass reset:true to zero the counters after reading (e.g. at the start of a new job).", inputSchema: { reset: z.boolean().optional() }, + outputSchema: METRICS_OUTPUT_SHAPE, }, async (args) => { const snapshot = metricsSnapshot(); diff --git a/src/server/tools/navigate.ts b/src/server/tools/navigate.ts index 9115a20..aaf9010 100644 --- a/src/server/tools/navigate.ts +++ b/src/server/tools/navigate.ts @@ -9,6 +9,8 @@ import { DEFAULT_GOTO, gotoWithRetry } from "../../net/navigate.js"; import { jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; +const navigateOutputShape = { url: z.string(), title: z.string() }; + /** Register `browser_navigate`. */ export function registerNavigateTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -17,6 +19,7 @@ export function registerNavigateTool(server: McpServer, sessions: SessionManager title: "Navigate", description: "Navigate a live session to a URL and return the resulting title.", inputSchema: { sessionId: z.string(), url: z.string(), waitMs: z.number().int().optional() }, + outputSchema: navigateOutputShape, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/pdf.ts b/src/server/tools/pdf.ts index 8d78daf..f1305ae 100644 --- a/src/server/tools/pdf.ts +++ b/src/server/tools/pdf.ts @@ -31,6 +31,13 @@ async function renderPdf(page: Page, opts: PdfOptions): Promise { }); } +/** Merged success shape: `path` branch (on-disk) or `pdfBase64` branch (inline). */ +export const PDF_OUTPUT_SHAPE = { + path: z.string().optional(), + pdfBase64: z.string().optional(), + bytes: z.number(), +}; + /** Register `browser_pdf`. */ export function registerPdfTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -46,6 +53,7 @@ export function registerPdfTool(server: McpServer, sessions: SessionManager): vo landscape: z.boolean().optional(), printBackground: z.boolean().optional(), }, + outputSchema: PDF_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/permissions.ts b/src/server/tools/permissions.ts index af30d1b..1869119 100644 --- a/src/server/tools/permissions.ts +++ b/src/server/tools/permissions.ts @@ -36,6 +36,13 @@ export async function applyPermissions( return context.grantPermissions(permissions, origin ? { origin } : undefined); } +/** Merged success shape across the 2 actions (grant/clear). */ +export const PERMISSIONS_OUTPUT_SHAPE = { + cleared: z.literal(true).optional(), + granted: z.array(z.string()).optional(), + origin: z.string().optional(), +}; + /** Register `browser_permissions`. */ export function registerPermissionsTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -49,6 +56,7 @@ export function registerPermissionsTool(server: McpServer, sessions: SessionMana origin: z.string().optional(), action: z.enum(["grant", "clear"]).default("grant"), }, + outputSchema: PERMISSIONS_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/probe-output-parts.ts b/src/server/tools/probe-output-parts.ts new file mode 100644 index 0000000..f946aef --- /dev/null +++ b/src/server/tools/probe-output-parts.ts @@ -0,0 +1,76 @@ +/** + * Probe-only zod `outputSchema` pieces: browser identity, consent handling, + * currency alignment, captcha outcome, visual observation, and the + * replay/site-memory bookkeeping blocks of a probe report. + * @module server/tools/probe-output-parts + */ +import { z } from "zod"; + +/** Matches `interfaces/report.ts#Identity`. */ +export const identitySchema = z.object({ + countryCode: z.string(), + locale: z.string(), + timezoneId: z.string(), + currency: z.string(), + geolocation: z.object({ latitude: z.number(), longitude: z.number() }), + acceptLanguage: z.string(), + realisticProfile: z.boolean(), + persistentProfile: z.boolean(), + userDataDir: z.string().nullable(), + proxyEnabled: z.boolean(), + proxyUrl: z.string().nullable(), + proxySource: z.string().nullable(), + proxyCountryCode: z.string().nullable(), + proxyRequiredForIpAlignment: z.boolean(), +}); + +/** Matches `interfaces/report.ts#ConsentResult`. */ +export const consentSchema = z.object({ + handled: z.boolean(), + target: z.string().optional(), + strategy: z.string().optional(), +}); + +/** Matches `interfaces/report.ts#CurrencyResult`. */ +export const currencyResultSchema = z.object({ + countryCode: z.string(), + preferred: z.string(), + detected: z.string().nullable(), + handled: z.boolean(), + mismatch: z.boolean().optional(), + detectedAfter: z.string().nullable().optional(), + reason: z.string().optional(), +}); + +/** Matches `interfaces/net.ts#CaptchaOutcome`. */ +export const captchaOutcomeSchema = z.object({ + attempted: z.boolean(), + solved: z.boolean(), + kind: z.enum(["recaptcha", "turnstile"]).optional(), + provider: z.enum(["2captcha", "anticaptcha", "capmonster"]).optional(), + reason: z.string().optional(), +}); + +/** Matches `interfaces/extraction.ts#Visual` (or `{}` when unobserved). */ +export const visualSchema = z.object({ + screenshotPath: z.string().optional(), + viewport: z.object({ width: z.number(), height: z.number() }).optional(), + interactiveElements: z.array(z.unknown()).optional(), +}); + +/** + * Matches the probe report's `replay` block. `steps` stays permissive: + * each step's `action`/before-after DOM signatures are internal debug detail. + */ +export const replaySchema = z.object({ + enabled: z.boolean(), + steps: z.array(z.unknown()), + dir: z.string().nullable(), +}); + +/** Matches the probe report's `siteMemory` block. */ +export const siteMemorySchema = z.object({ + enabled: z.boolean(), + updated: z.boolean(), + filePath: z.string(), +}); diff --git a/src/server/tools/probe-output-schema.ts b/src/server/tools/probe-output-schema.ts new file mode 100644 index 0000000..e760722 --- /dev/null +++ b/src/server/tools/probe-output-schema.ts @@ -0,0 +1,46 @@ +/** + * Assembled `outputSchema` raw shape for `browser_probe` / `browser_probe_html` + * — mirrors `compactReport()` (`agent/compact.ts`) field-for-field. + * @module server/tools/probe-output-schema + */ +import { z } from "zod"; +import { actionResultSchema } from "./schemas-action-output.js"; +import { contactsSchema } from "./schemas-contacts-output.js"; +import { challengesSchema, hotelOffersSchema } from "./schemas-hotel-challenges-output.js"; +import { priceSchema } from "./schemas-price-output.js"; +import { serpSchema } from "./schemas-serp-output.js"; +import { + captchaOutcomeSchema, + consentSchema, + currencyResultSchema, + identitySchema, + replaySchema, + siteMemorySchema, + visualSchema, +} from "./probe-output-parts.js"; + +/** Raw shape passed as `outputSchema` to both `browser_probe` tools. */ +export const probeReportShape = { + title: z.string(), + url: z.string(), + realtime: z.boolean(), + domChanged: z.boolean(), + text: z.string(), + prices: z.array(priceSchema), + hotelOffers: hotelOffersSchema, + challenges: challengesSchema, + captcha: captchaOutcomeSchema.optional(), + serp: serpSchema.optional(), + contacts: contactsSchema.optional(), + fastPath: z.boolean().optional(), + visual: visualSchema, + actions: z.array(actionResultSchema), + replay: replaySchema, + siteMemory: siteMemorySchema, + currency: currencyResultSchema, + identity: identitySchema, + consent: consentSchema, + screenshotPath: z.string(), + reportPath: z.string(), + storageStatePath: z.string().nullable(), +}; diff --git a/src/server/tools/probe.ts b/src/server/tools/probe.ts index d90886b..8cbd538 100644 --- a/src/server/tools/probe.ts +++ b/src/server/tools/probe.ts @@ -9,6 +9,7 @@ import { BudgetExhaustedError, CircuitOpenError, GuardrailViolation, QueueFullEr import { toAgentOptions, toProbeOptions } from "../map-options.js"; import { errorResult, jsonResult } from "../result.js"; import { probeHtmlShape, probeShape } from "../schemas.js"; +import { probeReportShape } from "./probe-output-schema.js"; /** Expected, user-actionable probe failures returned as an error result (not thrown). */ function isProbeUserError(err: unknown): boolean { @@ -29,6 +30,7 @@ export function registerProbeTools(server: McpServer): void { description: "Open a real browser page and return text, screenshot path, network/console logs, optional prices, visual observation and challenge detection.", inputSchema: probeShape, + outputSchema: probeReportShape, }, async (args) => { const raw = args as Record; @@ -49,6 +51,7 @@ export function registerProbeTools(server: McpServer): void { title: "Browser probe HTML", description: "Probe an inline HTML fixture with the same engine. Useful for tests and dry-runs.", inputSchema: probeHtmlShape, + outputSchema: probeReportShape, }, async (args) => { const raw = args as Record; diff --git a/src/server/tools/products.ts b/src/server/tools/products.ts index 5f2fb9d..a99dfa9 100644 --- a/src/server/tools/products.ts +++ b/src/server/tools/products.ts @@ -11,6 +11,17 @@ import type { SessionManager } from "../../session/manager.js"; import { jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; +/** One product card (matches `interfaces/products.ts#Product`). */ +const productSchema = z.object({ + title: z.string(), + price: z.number(), + currency: z.string(), + url: z.string().optional(), +}); + +/** `browser_products` output shape: the extracted product cards. */ +export const PRODUCTS_OUTPUT_SHAPE = { url: z.string(), count: z.number(), products: z.array(productSchema) }; + /** Register `browser_products`. */ export function registerProductsTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -24,6 +35,7 @@ export function registerProductsTool(server: McpServer, sessions: SessionManager limit: z.number().int().positive().optional(), containerSelector: z.string().optional(), }, + outputSchema: PRODUCTS_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/route.ts b/src/server/tools/route.ts index a9fc8a7..f16c593 100644 --- a/src/server/tools/route.ts +++ b/src/server/tools/route.ts @@ -53,6 +53,12 @@ export async function applyRoute( }); } +/** Shape of the `browser_route` success payload. */ +export const ROUTE_OUTPUT_SHAPE = { + routed: z.string(), + action: z.enum(["mock", "abort", "unroute"]), +}; + /** Register `browser_route`. */ export function registerRouteTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -68,6 +74,7 @@ export function registerRouteTool(server: McpServer, sessions: SessionManager): body: z.string().optional(), contentType: z.string().optional(), }, + outputSchema: ROUTE_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/run.ts b/src/server/tools/run.ts index 669c872..bd6dfeb 100644 --- a/src/server/tools/run.ts +++ b/src/server/tools/run.ts @@ -9,8 +9,12 @@ import { type RunStep, runSteps } from "../../agent/run-steps.js"; import { preflight } from "../../guardrails/preflight.js"; import type { SessionManager } from "../../session/manager.js"; import { errorResult, jsonResult } from "../result.js"; +import { actionResultSchema } from "./schemas-action-output.js"; import { withSession } from "./with-session.js"; +/** `browser_run` output shape: overall success flag, per-step results, final URL. */ +export const RUN_OUTPUT_SHAPE = { ok: z.boolean(), steps: z.array(actionResultSchema), url: z.string() }; + const stepSchema = z.object({ type: z.string() }).catchall(z.unknown()); /** Register `browser_run`. */ @@ -26,6 +30,7 @@ export function registerRunTool(server: McpServer, sessions: SessionManager): vo steps: z.array(stepSchema), humanApproved: z.boolean().optional(), }, + outputSchema: RUN_OUTPUT_SHAPE, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/schemas-action-output.ts b/src/server/tools/schemas-action-output.ts new file mode 100644 index 0000000..0cae5e4 --- /dev/null +++ b/src/server/tools/schemas-action-output.ts @@ -0,0 +1,13 @@ +/** + * Shared zod `outputSchema` piece for a single browser action result, reused + * by the probe (actions/replay) and run tool outputs. + * @module server/tools/schemas-action-output + */ +import { z } from "zod"; + +/** + * A normalized action result (matches `interfaces/types.ts#ActionResult`). + * `type`/`ok` are always present; extra per-action fields pass through + * unvalidated via `catchall` since each action kind adds its own detail. + */ +export const actionResultSchema = z.object({ type: z.string(), ok: z.boolean() }).catchall(z.unknown()); diff --git a/src/server/tools/schemas-contacts-output.ts b/src/server/tools/schemas-contacts-output.ts new file mode 100644 index 0000000..e4179cc --- /dev/null +++ b/src/server/tools/schemas-contacts-output.ts @@ -0,0 +1,13 @@ +/** + * Shared zod `outputSchema` piece for extracted page contacts, reused by the + * probe and fetch tool outputs. + * @module server/tools/schemas-contacts-output + */ +import { z } from "zod"; + +/** Matches `interfaces/contacts.ts#Contacts`. */ +export const contactsSchema = z.object({ + emails: z.array(z.string()), + phones: z.array(z.string()), + hasContactForm: z.boolean(), +}); diff --git a/src/server/tools/schemas-fetch-output.ts b/src/server/tools/schemas-fetch-output.ts new file mode 100644 index 0000000..165b1a8 --- /dev/null +++ b/src/server/tools/schemas-fetch-output.ts @@ -0,0 +1,21 @@ +/** + * Shared zod `outputSchema` pieces for the HTTP fast-path fetch result, + * reused by the fetch, fetch-batch, and crawl tool outputs. + * @module server/tools/schemas-fetch-output + */ +import { z } from "zod"; + +/** A single rendered fetch (matches `agent/fetch-render.ts#RenderedFetch`). */ +export const renderedFetchSchema = z.object({ + status: z.number(), + url: z.string(), + format: z.enum(["markdown", "text"]), + escalated: z.boolean(), + text: z.string(), +}); + +/** A batch/crawl entry that failed instead of resolving. */ +export const urlErrorSchema = z.object({ url: z.string(), error: z.string() }); + +/** A crawled page (matches `agent/crawl.ts#CrawlPage`). */ +export const crawlPageSchema = renderedFetchSchema.extend({ depth: z.number() }); diff --git a/src/server/tools/schemas-hotel-challenges-output.ts b/src/server/tools/schemas-hotel-challenges-output.ts new file mode 100644 index 0000000..5477c84 --- /dev/null +++ b/src/server/tools/schemas-hotel-challenges-output.ts @@ -0,0 +1,27 @@ +/** + * Shared zod `outputSchema` pieces for hotel-offer and anti-bot-challenge + * detection, reused by the probe and extract tool outputs. Every field is + * optional so the same schema also validates the `{}` empty variant that + * `interfaces/report.ts#ProbeReport` allows when the feature was skipped. + * @module server/tools/schemas-hotel-challenges-output + */ +import { z } from "zod"; + +const hotelOfferSchema = z.object({ provider: z.string(), currency: z.string(), amount: z.number() }); + +/** Matches `interfaces/extraction.ts#HotelOffers` (or `{}` when unextracted). */ +export const hotelOffersSchema = z.object({ + headline: z.object({ currency: z.string(), amount: z.number() }).nullable().optional(), + options: z.array(hotelOfferSchema).optional(), + bestTotal: hotelOfferSchema.nullable().optional(), +}); + +/** Matches `interfaces/extraction.ts#Challenges` (or `{}` when undetected). */ +export const challengesSchema = z.object({ + captcha: z.boolean().optional(), + turnstile: z.boolean().optional(), + hcaptcha: z.boolean().optional(), + cloudflare: z.boolean().optional(), + login: z.boolean().optional(), + otp: z.boolean().optional(), +}); diff --git a/src/server/tools/schemas-price-output.ts b/src/server/tools/schemas-price-output.ts new file mode 100644 index 0000000..a8d229c --- /dev/null +++ b/src/server/tools/schemas-price-output.ts @@ -0,0 +1,23 @@ +/** + * Shared zod `outputSchema` pieces for prices and scroll-collected rows, + * reused by probe, extract, collect, and collect-batch tool outputs. + * @module server/tools/schemas-price-output + */ +import { z } from "zod"; + +/** A price detected in page text (matches `interfaces/extraction.ts#Price`). */ +export const priceSchema = z.object({ + currency: z.string(), + amount: z.number(), + line: z.string(), + lineNo: z.number(), + context: z.string().optional(), +}); + +/** A row harvested by scroll-collect (matches `interfaces/extraction.ts#CollectedItem`). */ +export const collectedItemSchema = z.object({ + key: z.string(), + text: z.string(), + url: z.string().nullable(), + prices: z.array(priceSchema).optional(), +}); diff --git a/src/server/tools/schemas-serp-output.ts b/src/server/tools/schemas-serp-output.ts new file mode 100644 index 0000000..0ac3c80 --- /dev/null +++ b/src/server/tools/schemas-serp-output.ts @@ -0,0 +1,32 @@ +/** + * Shared zod `outputSchema` pieces for Google SERP shapes, reused by the + * probe and serp-batch tool outputs. + * @module server/tools/schemas-serp-output + */ +import { z } from "zod"; + +/** One SERP entry (matches `interfaces/serp.ts#SerpResult`). */ +export const serpResultSchema = z.object({ + position: z.number(), + title: z.string(), + url: z.string(), + displayUrl: z.string().optional(), + snippet: z.string().optional(), +}); + +/** Where a domain ranks in a SERP (matches `interfaces/serp.ts#DomainRank`). */ +export const domainRankSchema = z.object({ + domain: z.string(), + organic: z.array(z.number()), + ads: z.array(z.number()), + best: z.number().nullable(), + found: z.boolean(), +}); + +/** A parsed Google search page (matches `interfaces/serp.ts#Serp`). */ +export const serpSchema = z.object({ + organic: z.array(serpResultSchema), + ads: z.array(serpResultSchema), + related: z.array(z.string()), + rank: domainRankSchema.optional(), +}); diff --git a/src/server/tools/schemas-shots-output.ts b/src/server/tools/schemas-shots-output.ts new file mode 100644 index 0000000..bda889b --- /dev/null +++ b/src/server/tools/schemas-shots-output.ts @@ -0,0 +1,16 @@ +/** + * Shared zod `outputSchema` piece for a saved responsive screenshot, reused + * by the shots-batch and site-shots tool outputs. + * @module server/tools/schemas-shots-output + */ +import { z } from "zod"; + +/** One saved screenshot (matches `agent/shots.ts#Shot`). */ +export const shotSchema = z.object({ + viewport: z.string(), + width: z.number(), + height: z.number(), + path: z.string(), + scrollJacked: z.boolean().optional(), + frame: z.number().optional(), +}); diff --git a/src/server/tools/screenshot-result.ts b/src/server/tools/screenshot-result.ts new file mode 100644 index 0000000..da9c33b --- /dev/null +++ b/src/server/tools/screenshot-result.ts @@ -0,0 +1,77 @@ +/** + * Result builders for `browser_screenshot`. Every builder emits a + * `structuredContent` payload conforming to `screenshotOutputShape`, required + * once the tool declares an `outputSchema` — the SDK (^1.29) throws `McpError` + * at runtime if a non-error `CallToolResult` lacks matching `structuredContent`. + * @module server/tools/screenshot-result + */ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +/** Raw zod shape describing `browser_screenshot`'s structured metadata. */ +export const screenshotOutputShape = { + kind: z.enum(["element", "page", "multi", "annotated"]), + count: z.number(), + mimeType: z.string(), + notes: z.array(z.string()).optional(), + url: z.string().optional(), + marks: z.number().optional(), +}; + +type Structured = z.infer>; + +/** Single image + text note + structured metadata, shared by the single-image branches. */ +function single(base64: string, mimeType: string, note: string, structured: Structured): CallToolResult { + return { + content: [ + { type: "image", data: base64, mimeType }, + { type: "text", text: note }, + ], + structuredContent: structured, + }; +} + +/** Single-element capture (`ref`). */ +export function elementScreenshotResult(base64: string, ref: number | string): CallToolResult { + const note = `element ref=${ref}`; + return single(base64, "image/png", note, { + kind: "element", + count: 1, + mimeType: "image/png", + notes: [note], + }); +} + +/** Full-page / default single-viewport capture. */ +export function pageScreenshotResult(base64: string, url: string): CallToolResult { + const note = `screenshot of ${url}`; + return single(base64, "image/png", note, { kind: "page", count: 1, mimeType: "image/png", url, notes: [note] }); +} + +/** Annotated capture (`annotate: true`). Kept as JPEG to match `annotatedScreenshot`. */ +export function annotatedScreenshotResult(base64: string, url: string, marks: number): CallToolResult { + return single(base64, "image/jpeg", JSON.stringify({ url, marks }), { + kind: "annotated", + count: 1, + mimeType: "image/jpeg", + url, + marks, + }); +} + +/** Multi-viewport capture: several images, each preceded by a label note. */ +export function multiScreenshotResult(items: Array<{ base64: string; note: string }>): CallToolResult { + const content = items.flatMap((it) => [ + { type: "text" as const, text: it.note }, + { type: "image" as const, data: it.base64, mimeType: "image/png" as const }, + ]); + return { + content, + structuredContent: { + kind: "multi", + count: items.length, + mimeType: "image/png", + notes: items.map((it) => it.note), + }, + }; +} diff --git a/src/server/tools/screenshot.ts b/src/server/tools/screenshot.ts index c2d8429..a13831b 100644 --- a/src/server/tools/screenshot.ts +++ b/src/server/tools/screenshot.ts @@ -12,7 +12,14 @@ import { applyColorScheme } from "../../lib/color-scheme.js"; import { type ViewportInput, resolveViewport, viewportLabel } from "../../engine/viewport.js"; import type { SessionManager } from "../../session/manager.js"; import { settleForCapture } from "../../state/settle-capture.js"; -import { errorResult, imageJsonResult, imageResult, multiImageResult } from "../result.js"; +import { errorResult } from "../result.js"; +import { + annotatedScreenshotResult, + elementScreenshotResult, + multiScreenshotResult, + pageScreenshotResult, + screenshotOutputShape, +} from "./screenshot-result.js"; import { withSession } from "./with-session.js"; const VIEWPORT_SCHEMA = z.union([ @@ -38,6 +45,7 @@ export function registerScreenshotTool(server: McpServer, sessions: SessionManag themeClass: z.string().optional(), annotate: z.boolean().optional(), }, + outputSchema: screenshotOutputShape, }, async (args) => { const a = args as Record; @@ -51,7 +59,7 @@ export function registerScreenshotTool(server: McpServer, sessions: SessionManag const locator = refLocator(s.page, a.ref); if (!locator || (await locator.count()) === 0) return errorResult("ref_not_found"); const buf = await locator.screenshot({ timeout: 5_000 }); - return imageResult(buf.toString("base64"), `element ref=${a.ref}`); + return elementScreenshotResult(buf.toString("base64"), a.ref); } const fullPage = Boolean(a.fullPage); const list = (Array.isArray(a.viewports) ? a.viewports : a.viewport ? [a.viewport] : []) as ViewportInput[]; @@ -59,10 +67,10 @@ export function registerScreenshotTool(server: McpServer, sessions: SessionManag if (a.annotate === true) { await captureSnapshot(s.page); const shot = await annotatedScreenshot(s.page); - return imageJsonResult(shot.base64, { url: s.page.url(), marks: shot.marks }); + return annotatedScreenshotResult(shot.base64, s.page.url(), shot.marks); } const buffer = await s.page.screenshot({ fullPage }); - return imageResult(buffer.toString("base64"), `screenshot of ${s.page.url()}`); + return pageScreenshotResult(buffer.toString("base64"), s.page.url()); } const original = s.page.viewportSize(); const shots: Array<{ base64: string; note: string }> = []; @@ -73,7 +81,7 @@ export function registerScreenshotTool(server: McpServer, sessions: SessionManag shots.push({ base64: buf.toString("base64"), note: viewportLabel(v) }); } if (original) await s.page.setViewportSize(original); - return multiImageResult(shots); + return multiScreenshotResult(shots); } finally { if (restore) await restore(); } diff --git a/src/server/tools/serp-batch.ts b/src/server/tools/serp-batch.ts index 5256035..b122be8 100644 --- a/src/server/tools/serp-batch.ts +++ b/src/server/tools/serp-batch.ts @@ -10,6 +10,18 @@ import { toAgentOptions } from "../map-options.js"; import { progressReporter } from "../progress.js"; import { jsonResult } from "../result.js"; import { agentOptionShape } from "../schemas.js"; +import { domainRankSchema, serpResultSchema } from "./schemas-serp-output.js"; + +/** One query's outcome (matches `interfaces/serp.ts#SerpBatchRow`). */ +const serpBatchRowSchema = z.object({ + query: z.string(), + rank: domainRankSchema.optional(), + results: z.array(serpResultSchema), + error: z.string().optional(), +}); + +/** `browser_serp_batch` output shape: one SERP row per query. */ +export const SERP_BATCH_OUTPUT_SHAPE = { rows: z.array(serpBatchRowSchema) }; /** Register `browser_serp_batch`. */ export function registerSerpBatchTool(server: McpServer): void { @@ -28,6 +40,7 @@ export function registerSerpBatchTool(server: McpServer): void { delayMs: z.number().int().optional(), ...agentOptionShape, }, + outputSchema: SERP_BATCH_OUTPUT_SHAPE, }, async (args, extra) => { const a = args as Record; diff --git a/src/server/tools/session.ts b/src/server/tools/session.ts index 7945272..93be170 100644 --- a/src/server/tools/session.ts +++ b/src/server/tools/session.ts @@ -10,13 +10,31 @@ import type { SessionManager } from "../../session/manager.js"; import { toAgentOptions } from "../map-options.js"; import { jsonResult } from "../result.js"; import { agentOptionShape } from "../schemas.js"; +import { identitySchema } from "./probe-output-parts.js"; import { withSession } from "./with-session.js"; +/** Exported for tests to validate `structuredContent` against this shape. */ +export const openOutputShape = { sessionId: z.string(), expiresAt: z.number(), identity: identitySchema }; + +/** Two success shapes (list vs one session) share this tool; all optional so both validate. */ +const statusOutputShape = { + sessions: z.array(z.object({ sessionId: z.string(), url: z.string(), expiresAt: z.number() })).optional(), + sessionId: z.string().optional(), url: z.string().optional(), + createdAt: z.number().optional(), expiresAt: z.number().optional(), +}; + +const closeOutputShape = { closed: z.boolean() }; + /** Register `browser_open`, `browser_status`, `browser_close`. */ export function registerSessionTools(server: McpServer, sessions: SessionManager): void { server.registerTool( "browser_open", - { title: "Open browser session", description: "Open a persistent browser session and return its id.", inputSchema: agentOptionShape }, + { + title: "Open browser session", + description: "Open a persistent browser session and return its id.", + inputSchema: agentOptionShape, + outputSchema: openOutputShape, + }, async (args) => { const config = resolveConfig(toAgentOptions(args as Record)); const s = await sessions.open(config); @@ -33,7 +51,12 @@ export function registerSessionTools(server: McpServer, sessions: SessionManager server.registerTool( "browser_status", - { title: "Session status", description: "Status of one session (sessionId) or all sessions.", inputSchema: { sessionId: z.string().optional() } }, + { + title: "Session status", + description: "Status of one session (sessionId) or all sessions.", + inputSchema: { sessionId: z.string().optional() }, + outputSchema: statusOutputShape, + }, async (args) => { const id = (args as Record).sessionId as string | undefined; if (!id) { @@ -49,7 +72,12 @@ export function registerSessionTools(server: McpServer, sessions: SessionManager server.registerTool( "browser_close", - { title: "Close session", description: "Close a browser session by id.", inputSchema: { sessionId: z.string() } }, + { + title: "Close session", + description: "Close a browser session by id.", + inputSchema: { sessionId: z.string() }, + outputSchema: closeOutputShape, + }, async (args) => jsonResult({ closed: await sessions.close(String((args as Record).sessionId)) }), ); } diff --git a/src/server/tools/shots-batch.ts b/src/server/tools/shots-batch.ts index 224409a..702597f 100644 --- a/src/server/tools/shots-batch.ts +++ b/src/server/tools/shots-batch.ts @@ -13,6 +13,14 @@ import { parseViewports } from "../../engine/viewport.js"; import { toAgentOptions } from "../map-options.js"; import { progressReporter } from "../progress.js"; import { jsonResult } from "../result.js"; +import { urlErrorSchema } from "./schemas-fetch-output.js"; +import { shotSchema } from "./schemas-shots-output.js"; + +/** `browser_shots_batch` output shape: saved shots or `{url,error}` per input URL. */ +export const SHOTS_BATCH_OUTPUT_SHAPE = { + count: z.number(), + results: z.array(z.union([z.object({ url: z.string(), shots: z.array(shotSchema) }), urlErrorSchema])), +}; /** Register `browser_shots_batch`. */ export function registerShotsBatchTool(server: McpServer): void { @@ -32,6 +40,7 @@ export function registerShotsBatchTool(server: McpServer): void { headless: z.boolean().optional(), proxyUrl: z.string().optional(), }, + outputSchema: SHOTS_BATCH_OUTPUT_SHAPE, }, async (args, extra) => { const a = args as Record; diff --git a/src/server/tools/site-shots.ts b/src/server/tools/site-shots.ts index a7dd66f..a8657a0 100644 --- a/src/server/tools/site-shots.ts +++ b/src/server/tools/site-shots.ts @@ -13,6 +13,19 @@ import { parseViewports } from "../../engine/viewport.js"; import { toAgentOptions } from "../map-options.js"; import { progressReporter } from "../progress.js"; import { jsonResult } from "../result.js"; +import { shotSchema } from "./schemas-shots-output.js"; + +/** One crawled page with its content + responsive shots (matches `agent/site-shots.ts#SitePage`). */ +const sitePageSchema = z.object({ + url: z.string(), + depth: z.number(), + text: z.string(), + shots: z.array(shotSchema), + shotsError: z.string().optional(), +}); + +/** `browser_site_shots` output shape: content + responsive shots per crawled page. */ +export const SITE_SHOTS_OUTPUT_SHAPE = { count: z.number(), pages: z.array(sitePageSchema) }; /** Register `browser_site_shots`. */ export function registerSiteShotsTool(server: McpServer): void { @@ -37,6 +50,7 @@ export function registerSiteShotsTool(server: McpServer): void { headless: z.boolean().optional(), proxyUrl: z.string().optional(), }, + outputSchema: SITE_SHOTS_OUTPUT_SHAPE, }, async (args, extra) => { const a = args as Record; diff --git a/src/server/tools/snapshot-output.ts b/src/server/tools/snapshot-output.ts new file mode 100644 index 0000000..4f8fc03 --- /dev/null +++ b/src/server/tools/snapshot-output.ts @@ -0,0 +1,67 @@ +/** + * outputSchema shapes for `browser_snapshot` and `browser_act`, mirroring + * `InteractiveElement` (interfaces/extraction.ts) and `SnapshotDiff` + * (extraction/snapshot-diff.ts) field-for-field. Once a tool declares an + * `outputSchema`, the SDK (^1.29) throws `McpError` at runtime if the returned + * `structuredContent` doesn't conform (see `screenshot-result.ts` for the same + * contract). Plain (non-`.strict()`) shapes: unknown/extra keys are silently + * stripped, never rejected. Extracted from `snapshot.ts` to keep it < 90 lines + * (same precedent as `run-act.ts`). + * @module server/tools/snapshot-output + */ +import { z } from "zod"; +import { actionResultShape } from "./act.js"; + +/** Mirrors `InteractiveElement` (interfaces/extraction.ts). */ +export const interactiveElementShape = z.object({ + index: z.number(), + ref: z.string().optional(), + frame: z.number().optional(), + tag: z.string(), + text: z.string(), + role: z.string().nullable(), + id: z.string().nullable(), + name: z.string().nullable(), + type: z.string().nullable(), + href: z.string().nullable(), + visible: z.boolean(), + box: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }), + value: z.string().nullable().optional(), + hasValue: z.boolean().optional(), + placeholder: z.string().nullable().optional(), + disabled: z.boolean().optional(), + checked: z.boolean().optional(), + options: z.array(z.string()).optional(), + ariaExpanded: z.string().nullable().optional(), + ariaControls: z.string().nullable().optional(), + obscured: z.boolean().optional(), + selector: z.string().nullable().optional(), +}); + +/** `browser_snapshot` outputSchema: `{ url, count, elements[, marks] }`. */ +export const snapshotOutputShape = { + url: z.string(), + count: z.number(), + elements: z.array(interactiveElementShape), + marks: z.number().optional(), +}; + +/** Mirrors `TextChange` (extraction/snapshot-diff.ts). */ +const textChangeShape = z.object({ index: z.number(), before: z.string(), after: z.string() }); + +/** Mirrors `SnapshotDiff` (extraction/snapshot-diff.ts). */ +const snapshotDiffShape = z.object({ + added: z.array(interactiveElementShape), + removed: z.array(interactiveElementShape), + textChanged: z.array(textChangeShape), + urlChanged: z.boolean(), + changed: z.boolean(), +}); + +/** `browser_act` outputSchema: `{ result, url, diff[, marks] }`. */ +export const actOutputShape = { + result: actionResultShape, + url: z.string(), + diff: snapshotDiffShape, + marks: z.number().optional(), +}; diff --git a/src/server/tools/snapshot.ts b/src/server/tools/snapshot.ts index 5ebddd3..c1e3ff4 100644 --- a/src/server/tools/snapshot.ts +++ b/src/server/tools/snapshot.ts @@ -14,6 +14,7 @@ import { diffSnapshots } from "../../extraction/snapshot-diff.js"; import type { SessionManager } from "../../session/manager.js"; import { errorResult, imageJsonResult, jsonResult } from "../result.js"; import { KIND, runAct } from "./run-act.js"; +import { actOutputShape, snapshotOutputShape } from "./snapshot-output.js"; import { withSession } from "./with-session.js"; /** Register `browser_snapshot` and `browser_act`. */ @@ -23,13 +24,22 @@ export function registerSnapshotTools(server: McpServer, sessions: SessionManage { title: "Snapshot", description: - "Return the indexed interactive elements of the live page, including those inside open Shadow DOM and iframes (same- and cross-origin). Use each element's `ref` (e.g. \"12\" or \"3:4\" for a sub-frame) with browser_act for deterministic targeting. Pass `selectors:true` to also get a durable CSS `selector` per element. Pass `annotate:true` for a Set-of-Marks JPEG screenshot with numbered badges (= each `ref`) — for vision models: they see the page and target by ref.", - inputSchema: { sessionId: z.string(), selectors: z.boolean().optional(), annotate: z.boolean().optional() }, + "Return the indexed interactive elements of the live page, including those inside open Shadow DOM and iframes (same- and cross-origin). Use each element's `ref` (e.g. \"12\" or \"3:4\" for a sub-frame) with browser_act for deterministic targeting. Pass `selectors:true` to also get a durable CSS `selector` per element. Pass `prune:true` to drop elements hidden for accessibility (aria-hidden, display:none, or an aria-hidden/display:none ancestor, or visibility:hidden/collapse) — off by default, output unchanged. Pass `annotate:true` for a Set-of-Marks JPEG screenshot with numbered badges (= each `ref`) — for vision models: they see the page and target by ref.", + inputSchema: { + sessionId: z.string(), + selectors: z.boolean().optional(), + annotate: z.boolean().optional(), + prune: z.boolean().optional(), + }, + outputSchema: snapshotOutputShape, }, async (args) => { const a = args as Record; return withSession(sessions, String(a.sessionId), async (s) => { - const elements = redactElements(await captureSnapshot(s.page, a.selectors === true), s.secrets); + const elements = redactElements( + await captureSnapshot(s.page, a.selectors === true, a.prune === true), + s.secrets, + ); const payload = { url: s.page.url(), count: elements.length, elements }; if (a.annotate !== true) return jsonResult(payload); const shot = await annotatedScreenshot(s.page); @@ -55,6 +65,7 @@ export function registerSnapshotTools(server: McpServer, sessions: SessionManage to: z.string().optional(), annotate: z.boolean().optional(), }, + outputSchema: actOutputShape, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/tabs.ts b/src/server/tools/tabs.ts index bce5d59..1c4c7f5 100644 --- a/src/server/tools/tabs.ts +++ b/src/server/tools/tabs.ts @@ -21,6 +21,12 @@ const DESCRIPTION = type TabAction = "list" | "new" | "select" | "close"; +/** Mirrors `TabInfo` (session/tabs.ts). Exported so tests can validate `structuredContent`. */ +export const tabsOutputShape = { + tabs: z.array(z.object({ index: z.number(), url: z.string(), title: z.string(), active: z.boolean() })), + active: z.number(), +}; + /** Apply a mutating tab action; `list` is a no-op (the listing happens after). */ async function applyAction(s: SessionData, action: TabAction, index: number, url?: string): Promise { if (action === "new") await openTab(s, url); @@ -49,6 +55,7 @@ export function registerTabsTool(server: McpServer, sessions: SessionManager): v index: z.number().optional(), url: z.string().optional(), }, + outputSchema: tabsOutputShape, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/vault.ts b/src/server/tools/vault.ts index 9121483..0e43f98 100644 --- a/src/server/tools/vault.ts +++ b/src/server/tools/vault.ts @@ -12,6 +12,13 @@ import { originAllowed } from "../../vault/resolve.js"; import { listEntries, loadVault } from "../../vault/store.js"; import { errorResult, jsonResult } from "../result.js"; +/** Mirrors `VaultMeta` (interfaces/vault.ts) — non-secret metadata only. Exported for tests. */ +export const vaultOutputShape = { + credentials: z.array( + z.object({ ref: z.string(), username: z.string(), hasTotp: z.boolean(), origins: z.array(z.string()) }), + ), +}; + /** Register `browser_vault` (list-only, metadata-only). */ export function registerVaultTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -24,6 +31,7 @@ export function registerVaultTool(server: McpServer, sessions: SessionManager): action: z.literal("list"), sessionId: z.string().optional(), }, + outputSchema: vaultOutputShape, }, async (args) => { const a = args as Record; diff --git a/src/server/tools/visual-diff.ts b/src/server/tools/visual-diff.ts index c1d2fbf..9209de4 100644 --- a/src/server/tools/visual-diff.ts +++ b/src/server/tools/visual-diff.ts @@ -14,6 +14,20 @@ import type { SessionManager } from "../../session/manager.js"; import { errorResult, jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; +/** Merged success shape across the 3 branches (a/b diff, baseline created, baseline diff). */ +export const VISUAL_DIFF_OUTPUT_SHAPE = { + width: z.number().optional(), + height: z.number().optional(), + diffPixels: z.number().optional(), + diffRatio: z.number().optional(), + regions: z + .array(z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() })) + .optional(), + diffImage: z.string().optional(), + baseline: z.string().optional(), + baselineCreated: z.literal(true).optional(), +}; + /** Register `browser_visual_diff`. */ export function registerVisualDiffTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -30,6 +44,7 @@ export function registerVisualDiffTool(server: McpServer, sessions: SessionManag fullPage: z.boolean().optional(), threshold: z.number().optional(), }, + outputSchema: VISUAL_DIFF_OUTPUT_SHAPE, }, async (args) => { const x = args as Record; diff --git a/src/server/tools/wait.ts b/src/server/tools/wait.ts index 22f2e01..2124648 100644 --- a/src/server/tools/wait.ts +++ b/src/server/tools/wait.ts @@ -7,8 +7,11 @@ import { z } from "zod"; import { waitForCondition } from "../../actions/wait-for.js"; import type { SessionManager } from "../../session/manager.js"; import { jsonResult } from "../result.js"; +import { actionResultShape } from "./act.js"; import { withSession } from "./with-session.js"; +const waitForOutputShape = { result: actionResultShape, url: z.string() }; + /** Register `browser_wait_for`. */ export function registerWaitTool(server: McpServer, sessions: SessionManager): void { server.registerTool( @@ -25,6 +28,7 @@ export function registerWaitTool(server: McpServer, sessions: SessionManager): v urlContains: z.string().optional(), timeoutMs: z.number().int().optional(), }, + outputSchema: waitForOutputShape, }, async (args) => { const a = args as Record; diff --git a/tests/integration/snapshot.test.ts b/tests/integration/snapshot.test.ts index 1118e61..0fc9be9 100644 --- a/tests/integration/snapshot.test.ts +++ b/tests/integration/snapshot.test.ts @@ -3,11 +3,14 @@ * (each interactive element tagged with a stable ref), then act on a chosen * ref deterministically. Runs under Node with a real headless Chromium. */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import assert from "node:assert/strict"; import { test } from "node:test"; import { resolveConfig } from "../../src/agent/config.js"; import { actByRef } from "../../src/actions/act-by-ref.js"; import { captureSnapshot } from "../../src/extraction/snapshot.js"; +import { createServer } from "../../src/server/server.js"; import { SessionManager } from "../../src/session/manager.js"; // Encoded data URL: an unencoded data: URL truncates at the first space, which @@ -74,3 +77,70 @@ test("pick types into a combobox and clicks the matching suggestion", { timeout: await sessions.close(session.id); } }); + +const PRUNE_PAGE = + "" + + "" + + ""; +const PRUNE_URL = `data:text/html,${encodeURIComponent(PRUNE_PAGE)}`; + +test( + "prune:true drops aria-hidden/display:none elements; prune omitted keeps the exact prior set", + { timeout: 120_000 }, + async () => { + const sessions = new SessionManager(); + const session = await sessions.open(resolveConfig({ headless: true, engine: "patchright" })); + try { + await session.page.goto(PRUNE_URL, { waitUntil: "domcontentloaded", timeout: 30_000 }); + + const unpruned = await captureSnapshot(session.page); + const unprunedTexts = unpruned.map((e) => e.text).sort(); + assert.deepEqual( + unprunedTexts, + ["HiddenAria", "HiddenDisplay", "Visible"], + "default (prune omitted) is byte-for-byte the pre-pruning behavior", + ); + + const pruned = await captureSnapshot(session.page, false, true); + const prunedTexts = pruned.map((e) => e.text).sort(); + assert.deepEqual(prunedTexts, ["Visible"], "prune:true drops the aria-hidden and display:none elements"); + } finally { + await sessions.close(session.id); + } + }, +); + +test( + "browser_snapshot and browser_act return structuredContent honoring their declared outputSchema (no McpError)", + { timeout: 120_000 }, + async () => { + const { server } = createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "0.0.0" }); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + const open = await client.callTool({ name: "browser_open", arguments: { headless: true, engine: "patchright" } }); + assert.equal(open.isError, undefined, "browser_open should not error"); + const { sessionId } = open.structuredContent as { sessionId: string }; + + await client.callTool({ + name: "browser_navigate", + arguments: { sessionId, url: URL }, + }); + + const snap = await client.callTool({ name: "browser_snapshot", arguments: { sessionId } }); + assert.equal(snap.isError, undefined, "browser_snapshot should not throw an outputSchema McpError"); + const { elements } = snap.structuredContent as { elements: Array<{ index: number; text: string }> }; + const button = elements.find((e) => e.text === "Go"); + assert.ok(button, "snapshot should list the Go button through the real MCP layer"); + + const act = await client.callTool({ + name: "browser_act", + arguments: { sessionId, kind: "click", ref: button.index }, + }); + assert.equal(act.isError, undefined, "browser_act should not throw an outputSchema McpError"); + } finally { + await client.close(); + } + }, +); diff --git a/tests/unit/collect-tool.test.ts b/tests/unit/collect-tool.test.ts new file mode 100644 index 0000000..1ebaf1a --- /dev/null +++ b/tests/unit/collect-tool.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { Page } from "playwright"; +import { z } from "zod"; +import { collectOutputShape } from "../../src/server/tools/collect-schema.js"; +import { registerCollectTool } from "../../src/server/tools/collect.js"; +import type { SessionManager } from "../../src/session/manager.js"; +import type { SessionData } from "../../src/session/session.js"; + +/** The SDK's own runtime gate: throws if `structuredContent` violates `outputSchema`. */ +const collectOutputSchema = z.object(collectOutputShape); + +type Handler = (args: Record) => Promise; + +/** Capture the registered handler from a fake McpServer (inlined: see MEMORY/LESSON.md on the DRY hook). */ +function grabHandler(): { server: McpServer; handler: () => Handler } { + let captured: Handler | undefined; + const server = { + registerTool: (_name: string, _cfg: unknown, fn: Handler) => { + captured = fn; + }, + } as unknown as McpServer; + return { server, handler: () => captured as Handler }; +} + +/** A page whose one scan step yields two rows then stops (reachedEnd). */ +function fakePage(): Page { + return { + evaluate: async () => ({ + items: [ + { key: "1", text: "Row 1 — CHF 12.00", url: "https://a.com/1" }, + { key: "2", text: "Row 2 — CHF 8.00", url: "https://a.com/2" }, + ], + geo: { moved: 0, atEnd: true, scrollHeight: 500 }, + }), + waitForTimeout: async () => {}, + } as unknown as Page; +} + +/** Session manager wired to one page. */ +function fakeSessions(page: Page): SessionManager { + const session = { id: "s", health: "ok", page } as unknown as SessionData; + return { + get: () => session, + markBusy: () => {}, + markIdle: () => {}, + } as unknown as SessionManager; +} + +describe("browser_collect", () => { + test("plain (no pipeline) -> items/steps/reachedEnd conform to outputSchema", async () => { + const { server, handler } = grabHandler(); + registerCollectTool(server, fakeSessions(fakePage())); + const res = await handler()({ sessionId: "s", item: ".row" }); + const payload = res.structuredContent as Record; + expect(payload.count).toBe(2); + expect(payload.reachedEnd).toBe(true); + expect(() => collectOutputSchema.parse(res.structuredContent)).not.toThrow(); + }); + + test("with a pipeline (extractPrices + numericFields) -> invalidCount/csv also conform", async () => { + const { server, handler } = grabHandler(); + registerCollectTool(server, fakeSessions(fakePage())); + const res = await handler()({ + sessionId: "s", + item: ".row", + extractPrices: true, + pipeline: { columns: ["key", "text"], emit: "csv" }, + }); + const payload = res.structuredContent as Record; + expect(payload.csv).toContain("key"); + expect(() => collectOutputSchema.parse(res.structuredContent)).not.toThrow(); + }); +}); diff --git a/tests/unit/cookies-tool.test.ts b/tests/unit/cookies-tool.test.ts index 22226d4..6ce0b22 100644 --- a/tests/unit/cookies-tool.test.ts +++ b/tests/unit/cookies-tool.test.ts @@ -2,9 +2,13 @@ import { describe, expect, test } from "bun:test"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { BrowserContext } from "playwright"; +import { z } from "zod"; import type { SessionManager } from "../../src/session/manager.js"; import type { SessionData } from "../../src/session/session.js"; -import { registerCookiesTool } from "../../src/server/tools/cookies.js"; +import { COOKIES_OUTPUT_SHAPE, registerCookiesTool } from "../../src/server/tools/cookies.js"; + +/** The SDK's own runtime gate: throws if `structuredContent` violates the tool's `outputSchema`. */ +const cookiesOutputSchema = z.object(COOKIES_OUTPUT_SHAPE); type Handler = (args: Record) => Promise; @@ -61,6 +65,7 @@ describe("browser_cookies", () => { expect((res.structuredContent as Record).cookies).toEqual([ { name: "sid", value: "1" }, ]); + expect(() => cookiesOutputSchema.parse(res.structuredContent)).not.toThrow(); }); test("set -> calls addCookies and returns {added}", async () => { @@ -71,6 +76,7 @@ describe("browser_cookies", () => { const res = await handler()({ sessionId: "s", action: "set", cookies }); expect(calls.added).toEqual(cookies); expect((res.structuredContent as Record).added).toBe(1); + expect(() => cookiesOutputSchema.parse(res.structuredContent)).not.toThrow(); }); test("clear -> calls clearCookies and returns {cleared}", async () => { @@ -80,6 +86,7 @@ describe("browser_cookies", () => { const res = await handler()({ sessionId: "s", action: "clear" }); expect(calls.cleared).toBe(1); expect((res.structuredContent as Record).cleared).toBe(true); + expect(() => cookiesOutputSchema.parse(res.structuredContent)).not.toThrow(); }); test("a thrown error becomes a cookies_failed error result", async () => { diff --git a/tests/unit/pdf-tool.test.ts b/tests/unit/pdf-tool.test.ts index 769cd92..d4bd94c 100644 --- a/tests/unit/pdf-tool.test.ts +++ b/tests/unit/pdf-tool.test.ts @@ -4,9 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; import type { SessionManager } from "../../src/session/manager.js"; import type { SessionData } from "../../src/session/session.js"; -import { registerPdfTool } from "../../src/server/tools/pdf.js"; +import { PDF_OUTPUT_SHAPE, registerPdfTool } from "../../src/server/tools/pdf.js"; + +/** The SDK's own runtime gate: throws if `structuredContent` violates the tool's `outputSchema`. */ +const pdfOutputSchema = z.object(PDF_OUTPUT_SHAPE); type Handler = (args: Record) => Promise; @@ -51,6 +55,7 @@ describe("browser_pdf", () => { expect(seen).toEqual({ format: "A4", landscape: true, printBackground: true }); const payload = res.structuredContent as Record; expect(Buffer.from(payload.pdfBase64 as string, "base64").toString()).toBe("%PDF-1.4 fake"); + expect(() => pdfOutputSchema.parse(res.structuredContent)).not.toThrow(); }); test("with path -> writes the file and returns {path}", async () => { @@ -63,6 +68,7 @@ describe("browser_pdf", () => { const res = await handler()({ sessionId: "s", path: out }); expect((res.structuredContent as Record).path).toBe(out); expect(readFileSync(out, "utf-8")).toBe("ONDISK"); + expect(() => pdfOutputSchema.parse(res.structuredContent)).not.toThrow(); }); test("page.pdf throwing -> clear headless-only error", async () => { diff --git a/tests/unit/probe-output-schema.test.ts b/tests/unit/probe-output-schema.test.ts new file mode 100644 index 0000000..2f9914d --- /dev/null +++ b/tests/unit/probe-output-schema.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { compactReport } from "../../src/agent/compact.js"; +import type { ProbeReport } from "../../src/interfaces/report.js"; +import { probeReportShape } from "../../src/server/tools/probe-output-schema.js"; + +/** The SDK's own runtime gate: throws if `structuredContent` violates `outputSchema`. */ +const probeOutputSchema = z.object(probeReportShape); + +/** A fully populated report — every optional feature turned on. */ +function fullReport(): ProbeReport { + return { + url: "https://example.com", + title: "Example", + realtime: false, + domChanged: false, + text: "hello world", + prices: [{ currency: "CHF", amount: 12.5, line: "CHF 12.50", lineNo: 3, context: "Tickets from" }], + hotelOffers: { + headline: { currency: "CHF", amount: 99 }, + options: [{ provider: "booking", currency: "CHF", amount: 99 }], + bestTotal: { provider: "booking", currency: "CHF", amount: 99 }, + }, + challenges: { captcha: false, turnstile: false, hcaptcha: false, cloudflare: false, login: false, otp: false }, + captcha: { attempted: true, solved: true, kind: "recaptcha", provider: "2captcha" }, + serp: { organic: [{ position: 1, title: "t", url: "https://a.com" }], ads: [], related: [] }, + contacts: { emails: ["a@b.com"], phones: [], hasContactForm: true }, + fastPath: false, + visual: { screenshotPath: "/tmp/x.png", viewport: { width: 1280, height: 800 }, interactiveElements: [] }, + consent: { handled: true, target: "#accept", strategy: "click" }, + currency: { countryCode: "CH", preferred: "CHF", detected: "CHF", handled: true }, + identity: { + countryCode: "CH", + locale: "fr-CH", + timezoneId: "Europe/Zurich", + currency: "CHF", + geolocation: { latitude: 46.2, longitude: 6.1 }, + acceptLanguage: "fr-CH", + realisticProfile: true, + persistentProfile: false, + userDataDir: null, + proxyEnabled: false, + proxyUrl: null, + proxySource: null, + proxyCountryCode: null, + proxyRequiredForIpAlignment: false, + }, + actions: [{ type: "click", ok: true, target: "#go", ms: 12 }], + replay: { enabled: true, steps: [], dir: "/tmp/replay" }, + siteMemory: { enabled: true, updated: true, filePath: "/tmp/memory.json" }, + network: [], + console: [], + screenshotPath: "/tmp/x.png", + reportPath: "/tmp/report.json", + storageStatePath: null, + }; +} + +describe("probe outputSchema", () => { + test("compactReport() of a fully populated report parses without throwing", () => { + const compact = compactReport(fullReport()); + expect(() => probeOutputSchema.parse(compact)).not.toThrow(); + }); + + test("compactReport() with the {} empty variants (hotelOffers/challenges/visual) also parses", () => { + const report = fullReport(); + report.hotelOffers = {}; + report.challenges = {}; + report.visual = {}; + report.captcha = undefined; + report.serp = undefined; + report.contacts = undefined; + const compact = compactReport(report); + expect(() => probeOutputSchema.parse(compact)).not.toThrow(); + }); +}); diff --git a/tests/unit/products-tool.test.ts b/tests/unit/products-tool.test.ts new file mode 100644 index 0000000..4572bc9 --- /dev/null +++ b/tests/unit/products-tool.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { Page } from "playwright"; +import { z } from "zod"; +import { PRODUCTS_OUTPUT_SHAPE, registerProductsTool } from "../../src/server/tools/products.js"; +import type { SessionManager } from "../../src/session/manager.js"; +import type { SessionData } from "../../src/session/session.js"; + +/** The SDK's own runtime gate: throws if `structuredContent` violates `outputSchema`. */ +const productsOutputSchema = z.object(PRODUCTS_OUTPUT_SHAPE); + +type Handler = (args: Record) => Promise; + +/** Capture the registered handler from a fake McpServer (inlined: see MEMORY/LESSON.md on the DRY hook). */ +function grabHandler(): { server: McpServer; handler: () => Handler } { + let captured: Handler | undefined; + const server = { + registerTool: (_name: string, _cfg: unknown, fn: Handler) => { + captured = fn; + }, + } as unknown as McpServer; + return { server, handler: () => captured as Handler }; +} + +/** A page whose DOM script always returns two fixed product cards. */ +function fakePage(): Page { + return { + url: () => "https://shop.example.com/search", + evaluate: async () => [ + { title: "Widget A", price: 19.9, currency: "CHF" }, + { title: "Widget B", price: 24.5, currency: "CHF", url: "https://shop.example.com/b" }, + ], + } as unknown as Page; +} + +/** Session manager wired to one page. */ +function fakeSessions(page: Page): SessionManager { + const session = { id: "s", health: "ok", page } as unknown as SessionData; + return { + get: () => session, + markBusy: () => {}, + markIdle: () => {}, + } as unknown as SessionManager; +} + +describe("browser_products", () => { + test("returns {url, count, products} conforming to outputSchema", async () => { + const { server, handler } = grabHandler(); + registerProductsTool(server, fakeSessions(fakePage())); + const res = await handler()({ sessionId: "s" }); + const payload = res.structuredContent as Record; + expect(payload.count).toBe(2); + expect(payload.url).toBe("https://shop.example.com/search"); + expect(() => productsOutputSchema.parse(res.structuredContent)).not.toThrow(); + }); +}); diff --git a/tests/unit/result.test.ts b/tests/unit/result.test.ts index fd1a815..90bf7eb 100644 --- a/tests/unit/result.test.ts +++ b/tests/unit/result.test.ts @@ -25,6 +25,28 @@ describe("imageJsonResult", () => { }); }); +describe("compact JSON encoding", () => { + test("jsonResult text block is compact (no pretty-print whitespace) yet parses back", () => { + const payload = { url: "https://x.com", count: 2, nested: { a: 1 } }; + const r = jsonResult(payload); + const text = r.content.find((c) => c.type === "text"); + const raw = text && "text" in text ? text.text : ""; + expect(raw).not.toContain("\n"); + expect(raw).toBe(JSON.stringify(payload)); + expect(JSON.parse(raw)).toEqual(payload); + expect(r.structuredContent).toEqual(payload); + }); + + test("imageJsonResult text block is compact and equivalent to structuredContent", () => { + const payload = { marks: 3, url: "https://x.com" }; + const r = imageJsonResult("BASE64", payload); + const text = r.content.find((c) => c.type === "text"); + const raw = text && "text" in text ? text.text : ""; + expect(raw).not.toContain("\n"); + expect(JSON.parse(raw)).toEqual(r.structuredContent); + }); +}); + describe("errorResult", () => { test("without code: text + isError, no structuredContent (back-compat)", () => { const r = errorResult("boom"); diff --git a/tests/unit/screenshot-tool.test.ts b/tests/unit/screenshot-tool.test.ts new file mode 100644 index 0000000..34eefee --- /dev/null +++ b/tests/unit/screenshot-tool.test.ts @@ -0,0 +1,97 @@ +/** + * Proves every `browser_screenshot` return branch emits a `structuredContent` + * payload that parses against `screenshotOutputShape` — required once the + * tool declares an `outputSchema`, or the SDK (^1.29) throws `McpError` at + * runtime for any branch missing/mismatching it. + * @module tests/unit/screenshot-tool + */ +import { describe, expect, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { screenshotOutputShape } from "../../src/server/tools/screenshot-result.js"; +import { registerScreenshotTool } from "../../src/server/tools/screenshot.js"; +import type { SessionManager } from "../../src/session/manager.js"; +import type { SessionData } from "../../src/session/session.js"; + +type Handler = (args: Record) => Promise; +const outputSchema = z.object(screenshotOutputShape); + +/** Fake McpServer that just stores the registered handler. */ +function mockServer(): { server: McpServer; getHandler: () => Handler } { + let stored: Handler | undefined; + const server = { + registerTool: (_name: string, _cfg: unknown, fn: Handler) => { + stored = fn; + }, + } as unknown as McpServer; + return { server, getHandler: () => stored as Handler }; +} + +/** Locator/Frame/Page fakes covering ref-locator, snapshot, and annotate paths. */ +function fakePage() { + const locator = { count: async () => 1, screenshot: async () => Buffer.from("PNG-ELEMENT") }; + const frame = { + locator: () => ({ first: () => locator }), + isDetached: () => false, + evaluate: async () => [], + }; + const page = { + frames: () => [frame], + evaluate: async () => 2, + screenshot: async () => Buffer.from("PNG-PAGE"), + url: () => "https://example.test/", + viewportSize: () => ({ width: 1280, height: 800 }), + setViewportSize: async () => {}, + waitForLoadState: async () => {}, + }; + return page; +} + +function fakeSessions(page: ReturnType): SessionManager { + const session = { id: "s", health: "ok", page } as unknown as SessionData; + return { + get: () => session, + markBusy: () => {}, + markIdle: () => {}, + } as unknown as SessionManager; +} + +describe("browser_screenshot outputSchema", () => { + test("element ref branch: structuredContent parses against the schema", async () => { + const { server, getHandler } = mockServer(); + registerScreenshotTool(server, fakeSessions(fakePage())); + const res = await getHandler()({ sessionId: "s", ref: 3 }); + expect(res.structuredContent).toBeDefined(); + expect(() => outputSchema.parse(res.structuredContent)).not.toThrow(); + expect(outputSchema.parse(res.structuredContent).kind).toBe("element"); + }); + + test("full-page branch: structuredContent parses against the schema", async () => { + const { server, getHandler } = mockServer(); + registerScreenshotTool(server, fakeSessions(fakePage())); + const res = await getHandler()({ sessionId: "s", fullPage: true }); + expect(res.structuredContent).toBeDefined(); + expect(() => outputSchema.parse(res.structuredContent)).not.toThrow(); + expect(outputSchema.parse(res.structuredContent).kind).toBe("page"); + }); + + test("multi-viewport branch: structuredContent parses against the schema", async () => { + const { server, getHandler } = mockServer(); + registerScreenshotTool(server, fakeSessions(fakePage())); + const res = await getHandler()({ sessionId: "s", viewports: ["mobile", "desktop"] }); + expect(res.structuredContent).toBeDefined(); + const parsed = outputSchema.parse(res.structuredContent); + expect(() => outputSchema.parse(res.structuredContent)).not.toThrow(); + expect(parsed).toMatchObject({ kind: "multi", count: 2 }); + }); + + test("annotate branch: structuredContent parses against the schema", async () => { + const { server, getHandler } = mockServer(); + registerScreenshotTool(server, fakeSessions(fakePage())); + const res = await getHandler()({ sessionId: "s", annotate: true }); + expect(res.structuredContent).toBeDefined(); + expect(() => outputSchema.parse(res.structuredContent)).not.toThrow(); + expect(outputSchema.parse(res.structuredContent).kind).toBe("annotated"); + }); +}); diff --git a/tests/unit/session-tool.test.ts b/tests/unit/session-tool.test.ts new file mode 100644 index 0000000..78eea9b --- /dev/null +++ b/tests/unit/session-tool.test.ts @@ -0,0 +1,34 @@ +/** + * `browser_open` output-schema validation: the handler's `structuredContent` + * must parse against the declared `outputSchema` (see server/tools/session.ts). + * @module tests/unit/session-tool + */ +import { describe, expect, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { resolveConfig } from "../../src/agent/config.js"; +import { openOutputShape, registerSessionTools } from "../../src/server/tools/session.js"; +import type { SessionManager } from "../../src/session/manager.js"; +import type { SessionData } from "../../src/session/session.js"; + +type Handler = (args: Record) => Promise; + +describe("browser_open outputSchema", () => { + test("structuredContent parses against the declared shape", async () => { + const config = resolveConfig({}); + const session = { id: "s1", page: { url: () => "about:blank" }, expiresAt: Date.now() + 60_000, config } as unknown as SessionData; + const sessions = { open: async () => session } as unknown as SessionManager; + + let handler: Handler | undefined; + const server = { + registerTool: (name: string, _c: unknown, fn: Handler) => { + if (name === "browser_open") handler = fn; + }, + } as unknown as McpServer; + registerSessionTools(server, sessions); + + const res = await (handler as Handler)({}); + expect(() => z.object(openOutputShape).parse(res.structuredContent)).not.toThrow(); + }); +}); diff --git a/tests/unit/snapshot-prune.test.ts b/tests/unit/snapshot-prune.test.ts new file mode 100644 index 0000000..a1a3437 --- /dev/null +++ b/tests/unit/snapshot-prune.test.ts @@ -0,0 +1,68 @@ +/** + * Unit tests for the opt-in `prune` pruning logic: the DOM-hiding predicate + * (`isHiddenForAriaMirror`, a Node-testable mirror of the browser-side + * `isElementHiddenForAria`) and the pure keep/drop decision (`shouldKeep`). + */ +import { describe, expect, test } from "bun:test"; +import { isHiddenForAriaMirror } from "../../src/extraction/snapshot-hidden.js"; +import { shouldKeep } from "../../src/extraction/snapshot.js"; + +describe("isHiddenForAriaMirror", () => { + test("visible leaf node is not hidden", () => { + expect(isHiddenForAriaMirror({})).toBe(false); + }); + + test("aria-hidden=true on the node itself hides it", () => { + expect(isHiddenForAriaMirror({ ariaHidden: "true" })).toBe(true); + }); + + test("aria-hidden=true on an ancestor hides it (sticky, cannot be undone)", () => { + const node = { ariaHidden: "false", parent: { ariaHidden: "true" } }; + expect(isHiddenForAriaMirror(node)).toBe(true); + }); + + test("display:none on an ancestor hides it (sticky)", () => { + const node = { parent: { parent: { display: "none" } } }; + expect(isHiddenForAriaMirror(node)).toBe(true); + }); + + test("display:contents on an ancestor does NOT hide it", () => { + const node = { parent: { display: "contents" } }; + expect(isHiddenForAriaMirror(node)).toBe(false); + }); + + test("visibility:hidden on the node itself hides it", () => { + expect(isHiddenForAriaMirror({ visibility: "hidden" })).toBe(true); + }); + + test("visibility:collapse on the node itself hides it", () => { + expect(isHiddenForAriaMirror({ visibility: "collapse" })).toBe(true); + }); + + test("an ancestor's visibility:hidden does not hide this node (overridable, non-sticky)", () => { + // getComputedStyle already resolves inheritance/override; a node whose OWN + // resolved visibility is "visible" (e.g. a descendant re-enabled it) must + // stay visible regardless of what an ancestor originally declared. + const node = { visibility: "visible", parent: { visibility: "hidden" } }; + expect(isHiddenForAriaMirror(node)).toBe(false); + }); +}); + +describe("shouldKeep", () => { + test("keeps a hidden element when prune is off (default, unchanged behavior)", () => { + expect(shouldKeep(true, false)).toBe(true); + }); + + test("drops a hidden element when prune is on", () => { + expect(shouldKeep(true, true)).toBe(false); + }); + + test("keeps a visible element regardless of prune", () => { + expect(shouldKeep(false, false)).toBe(true); + expect(shouldKeep(false, true)).toBe(true); + }); + + test("keeps an element with no ariaHidden flag (undefined) regardless of prune", () => { + expect(shouldKeep(undefined, true)).toBe(true); + }); +}); diff --git a/tests/unit/tabs-tool-output.test.ts b/tests/unit/tabs-tool-output.test.ts new file mode 100644 index 0000000..39bd576 --- /dev/null +++ b/tests/unit/tabs-tool-output.test.ts @@ -0,0 +1,32 @@ +/** + * `browser_tabs` output-schema validation: the handler's `structuredContent` + * must parse against the declared `outputSchema` (see server/tools/tabs.js). + * @module tests/unit/tabs-tool-output + */ +import { describe, expect, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { registerTabsTool, tabsOutputShape } from "../../src/server/tools/tabs.js"; +import type { SessionManager } from "../../src/session/manager.js"; +import { makeSession } from "./helpers/fake-tabs.js"; + +type Handler = (args: Record) => Promise; + +describe("browser_tabs outputSchema", () => { + test("structuredContent parses against the declared shape", async () => { + const { session } = makeSession(); + const sessions = { get: () => session, markBusy: () => {}, markIdle: () => {} } as unknown as SessionManager; + + let handler: Handler | undefined; + const server = { + registerTool: (_n: string, _c: unknown, fn: Handler) => { + handler = fn; + }, + } as unknown as McpServer; + registerTabsTool(server, sessions); + + const res = await (handler as Handler)({ sessionId: "s1", action: "list" }); + expect(() => z.object(tabsOutputShape).parse(res.structuredContent)).not.toThrow(); + }); +}); diff --git a/tests/unit/vault-tool-redact.test.ts b/tests/unit/vault-tool-redact.test.ts index 5de869f..9d1d717 100644 --- a/tests/unit/vault-tool-redact.test.ts +++ b/tests/unit/vault-tool-redact.test.ts @@ -4,10 +4,11 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { z } from "zod"; import { redactElements } from "../../src/extraction/redact.js"; import type { InteractiveElement } from "../../src/interfaces/extraction.js"; import type { SessionManager } from "../../src/session/manager.js"; -import { registerVaultTool } from "../../src/server/tools/vault.js"; +import { registerVaultTool, vaultOutputShape } from "../../src/server/tools/vault.js"; import { setEntry } from "../../src/vault/store.js"; type Handler = (args: Record) => Promise; @@ -42,6 +43,7 @@ describe("browser_vault (list-only)", () => { const creds = (res.structuredContent as { credentials: unknown[] }).credentials; expect(creds).toEqual([{ ref: "gh", username: "u", hasTotp: false, origins: ["https://github.com"] }]); expect(JSON.stringify(res)).not.toContain("p@ss"); + expect(() => z.object(vaultOutputShape).parse(res.structuredContent)).not.toThrow(); }); });