@mmerterden/multi-agent-toolkit-mcp 3.13.0 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,76 @@ Releases before this file exists are recorded in the git tags and commit history
19
19
 
20
20
  ---
21
21
 
22
+ ## 3.13.1 - 2026-09-21
23
+
24
+ Fixes to the web and context families, each held by a gate proven to fail
25
+ without it. The acceptance numbers at the end were measured against live sites.
26
+
27
+ ### Fixed
28
+
29
+ - **A question asked of the index returned nothing at all.** FTS5 reads the
30
+ space between two terms as AND, so a nine-word question demanded all nine
31
+ words from one chunk. `context_search` now runs the every-term query first,
32
+ because a caller who typed all the terms meant all of them, and falls back to
33
+ an any-term query when that finds nothing; `bm25` then ranks by how many of
34
+ the terms a chunk carries and how rare each one is. A log line still matches
35
+ exactly as before.
36
+ - **`web_crawl`, `web_extract` and `web_map` crawled `about:blank`.** Run before
37
+ the session has navigated, extraction succeeded and returned nothing: the
38
+ crawl reported one page, the index gained an empty document, and the next
39
+ search ranked it. The three now name the missing `web_goto` instead of
40
+ returning an empty success.
41
+ - **A bounded crawl spent its budget on site furniture.** Links were followed in
42
+ DOM order, so a global index, a feedback form and a breadcrumb came before the
43
+ second body link. Links inside the page's content region are handed back
44
+ first, and a link that differs from another only by its query string is
45
+ treated as the same document. Measured on a 20-page crawl of the Python
46
+ standard library index: 14 material pages before, 18 after.
47
+ - **Refs from inside a frame were never registered.** Playwright stamps a frame
48
+ generation onto refs handed out past the first navigation (`f2e8`, not `e8`),
49
+ so the registry accepted nothing from the second page onward and refused every
50
+ ref the snapshot plainly showed.
51
+ - **`web_click` reported a timeout without saying what caused it.** A refusal
52
+ now names the reason: disabled, not displayed, transparent, zero-sized,
53
+ off-viewport, `pointer-events: none`, or the element that is on top of it at
54
+ the click point.
55
+ - **Readable extraction fell back to the page source.** The Readability and
56
+ Turndown browser bundles are resolved by their browser filenames and injected
57
+ into the page; when neither is available the fallback reads the live content
58
+ container's rendered text and drops chrome line by line.
59
+ - **`context_search` said which lines matched but not which file.** Each hit now
60
+ carries its path.
61
+
62
+ ### Added
63
+
64
+ - **`web_crawl` feeds the full-text index.** Each page is written beside the
65
+ index and indexed under its URL, so twenty pages arrive searchable instead of
66
+ as a wall of text. Failure to index never fails the crawl.
67
+ - **An offloaded payload's summary carries its signal lines.** A head-and-tail
68
+ window keeps the two places a large payload is least likely to carry its
69
+ answer; the tool name is already known, so up to twenty matching lines are
70
+ surfaced with their line numbers - `FATAL` and `Abort trap` for a crash log,
71
+ `error:` and `Undefined symbol` for a build, tappable nodes for a UI dump,
72
+ measurements for a memory report. Patterns only, no model.
73
+ - **`@mozilla/readability` and `turndown` as optional peers**, alongside
74
+ `playwright`. Absent, extraction uses the fallback above.
75
+ - **`MCP_TOOLKIT_INDEX_DIR`** overrides where the index lives, so a gate ranks a
76
+ corpus it owns.
77
+
78
+ ### Acceptance
79
+
80
+ Run against live sites before release:
81
+
82
+ | Criterion | Result |
83
+ | ---------------------------------------------------------------------------------------- | --------------------- |
84
+ | Click by ref on ten real sites, asserting the page moved | 10/10 |
85
+ | Article body extracted from ten pages, no nav, cookie or footer text | 10/10 |
86
+ | `max_pages: 20` crawl of a documentation site, then five questions ranked from the index | 5/5 first-hit correct |
87
+
88
+ None of the five questions names the module it should find.
89
+
90
+ ---
91
+
22
92
  ## 3.13.0 - 2026-09-20
23
93
 
24
94
  ### Added
@@ -177,8 +247,7 @@ because the thing that broke was the assumption that the host is a Mac.
177
247
  `pass_inspect`, `pass_certificates`. Build, sign, validate and inspect Apple
178
248
  Wallet passes; offline, deterministic, and generic by construction. All five
179
249
  of Apple's styles are first-class with a per-style image contract, and no
180
- brand, airline or project appears anywhere in the directory. 95 tools become
181
- 99. Three are read-only; `pass_build` writes the file the caller named.
250
+ brand, airline or project appears anywhere in the directory. 95 tools become 99. Three are read-only; `pass_build` writes the file the caller named.
182
251
 
183
252
  **A literal passphrase is not an accepted input.** The caller names an
184
253
  environment variable (`passphrase_env`) or a keychain entry, and the value
@@ -576,7 +645,7 @@ unverifiable without a device or emulator to run adb against.
576
645
 
577
646
  - **The accessibility audits reported a clean screen they had never read.** On a
578
647
  tree that came back empty they returned `elements_scanned: 0, total_issues: 0,
579
- critical: 0, important: 0, warning: 0` - a full clean bill of health,
648
+ critical: 0, important: 0, warning: 0` - a full clean bill of health,
580
649
  indistinguishable from an accessible screen, and the first reading is the one
581
650
  anyone believes. Both audits now carry `measurable` and a `reason`, and an
582
651
  unmeasurable run reports `total_issues: null`, never 0. A count of zero is a
@@ -704,7 +773,7 @@ declare `>= 3.1.0`.
704
773
  `~/.claude/logs/multi-agent-toolkit/<tool>-<timestamp>.txt`, and the tool
705
774
  returns a head + tail window with the line count and the path.
706
775
  `agent_query_output {pattern, path?, context_lines?, max_matches?,
707
- ignore_case?}` searches that file and returns matching lines with numbered
776
+ ignore_case?}` searches that file and returns matching lines with numbered
708
777
  context, so a follow-up question does not mean re-running an expensive tool -
709
778
  and for a UI dump a second run is not even the same evidence.
710
779
 
@@ -787,7 +856,7 @@ removed (still 83); input handling is tightened but stays backward-compatible.
787
856
  - `ios_record_video` and `android_record_screen` actually record. Both previously
788
857
  returned a literal "Run: ..." instruction for the caller to execute by hand. Now
789
858
  `action:"start"` spawns the recorder (`simctl io recordVideo` / `adb shell
790
- screenrecord`) in the background with PID tracking and returns immediately;
859
+ screenrecord`) in the background with PID tracking and returns immediately;
791
860
  `action:"stop"` interrupts it (SIGINT, so the mp4 container is finalized), pulls
792
861
  the file off the device on Android, and returns the local path. One recording
793
862
  per device; a stop with nothing running is an error.
@@ -1006,10 +1075,11 @@ Documentation that claimed more than it checked, and the gate that let it.
1006
1075
 
1007
1076
  **Editor support is now stated as verified vs compatible.** The README opened with
1008
1077
  "Works with Claude Code, Copilot CLI, Cursor, Antigravity, and VS Code Copilot Chat
1078
+
1009
1079
  - the multi-agent-pipeline's full-orchestration targets". That parenthetical stopped
1010
- being true in pipeline v10.7.0, when the Cursor / Antigravity / Codex / Copilot Chat
1011
- adapters were deleted; the pipeline has targeted Claude Code and Copilot CLI only
1012
- ever since.
1080
+ being true in pipeline v10.7.0, when the Cursor / Antigravity / Codex / Copilot Chat
1081
+ adapters were deleted; the pipeline has targeted Claude Code and Copilot CLI only
1082
+ ever since.
1013
1083
 
1014
1084
  The list is not simply wrong, though: this is a plain stdio MCP server, so it does
1015
1085
  run in any MCP client, and the config snippets for those clients stay. What was
@@ -1021,12 +1091,12 @@ of the 78 - in the primary host, for two releases, with every gate green.
1021
1091
  **Gate 3b: per-family counts, not just the headline.** Gate 3 checked the two
1022
1092
  headline numbers and nothing else, so every subcount drifted behind a green check:
1023
1093
 
1024
- | README said | reality |
1025
- |---|---|
1026
- | Device Control (42 tools) | 58 |
1027
- | Store Compliance (4 tools) | 3 |
1028
- | iOS Tools (34), diagram (31) | 35 |
1029
- | 5 categories summing to 57 | 78 |
1094
+ | README said | reality |
1095
+ | ---------------------------- | ------- |
1096
+ | Device Control (42 tools) | 58 |
1097
+ | Store Compliance (4 tools) | 3 |
1098
+ | iOS Tools (34), diagram (31) | 35 |
1099
+ | 5 categories summing to 57 | 78 |
1030
1100
 
1031
1101
  The last row is the interesting one: the whole `design_*` family, six tools, was
1032
1102
  missing from the category list entirely, which is why the listed categories could
@@ -1253,12 +1323,14 @@ content beside it. 14 gates, all green.
1253
1323
  ## [3.0.0] - 2026-08-22
1254
1324
 
1255
1325
  ### Changed
1326
+
1256
1327
  - **Renamed to `@mmerterden/multi-agent-toolkit-mcp`.** The old name read as internal scaffolding for one pipeline; this server is a standalone MCP over stdio with three runtime dependencies and no coupling to any orchestrator, and the name now says which family it belongs to. Major, because a package rename breaks every consumer that resolves it by name.
1257
1328
  - **The MCP server identity reported over the protocol is now `multi-agent-toolkit-mcp`.** Hosts key their registration off this, so an existing `dev-toolkit` entry does not upgrade in place - it has to be removed and re-added. The pipeline installer does that automatically; a hand-registered client needs `<cli> mcp remove dev-toolkit` once.
1258
1329
  - **`dev-toolkit-mcp` is kept as a second `bin` alias** so a script that invokes the old binary name keeps working through the transition.
1259
1330
  - Package description and keywords lead with what the server does (iOS Simulator, Android Emulator, headless web, 83 tools) rather than with the family it ships in, because that is what someone searching for it will search for.
1260
1331
 
1261
1332
  ### Migration
1333
+
1262
1334
  - `npm i -g @mmerterden/multi-agent-toolkit-mcp` then remove the old registration: `claude mcp remove dev-toolkit` (same for `copilot` / `codex`).
1263
1335
  - `@mmerterden/dev-toolkit-mcp` stays published at 2.26.0 and is deprecated with a pointer to the new name. Nothing is unpublished; a pinned consumer keeps resolving.
1264
1336
 
@@ -1480,7 +1552,7 @@ rarely the same size.
1480
1552
  size finding that buried the real defects. The comparison now reports
1481
1553
  per-element edge insets (left/right) rather than raw width, gaps between
1482
1554
  consecutive elements, vertical placement, and font size, family and text
1483
- colour sampled from the text ink rather than the box average - the box average
1555
+ colour sampled from the text ink rather than the box average - the box average
1484
1556
  previously returned the background colour and so passed every check. Pass
1485
1557
  `responsive: false` to restore the previous absolute-delta behaviour.
1486
1558
  - **Height is advisory.** It is still reported, but a content-driven height
@@ -1512,20 +1584,20 @@ because no failure ever carried `isError`, no host could tell.
1512
1584
  ### Fixed
1513
1585
 
1514
1586
  - **`isError` on every failure.** The `CallTool` dispatch returned command
1515
- failures as ordinary text, so a host - and the pipeline gates reading these
1516
- results - saw failure as success. Failures now return `isError: true`. Failing
1587
+ failures as ordinary text, so a host - and the pipeline gates reading these
1588
+ results - saw failure as success. Failures now return `isError: true`. Failing
1517
1589
  command output is also capped at 600 chars: a failed `simctl` call was
1518
1590
  inlining its entire ~3 KB usage page into the caller's context.
1519
1591
  - **`ios_biometric` was a no-op that claimed success.** `simctl keychain
1520
- <device> biometric-enroll` / `biometric-match` do not exist - `keychain`
1592
+ <device> biometric-enroll` / `biometric-match` do not exist - `keychain`
1521
1593
  supports only `add-root-cert`, `add-cert`, `reset`. Now drives the BiometricKit
1522
1594
  notification via `notifyutil` inside the simulator, and because `notifyutil`
1523
1595
  exits 0 even when it cannot post the name, its output is inspected: a "Failed
1524
1596
  with code N" line is reported as a failure with next steps instead of
1525
1597
  "success simulated".
1526
1598
  - **`ios_go_home` was a no-op.** `simctl io <device> pressButton` does not exist
1527
- - `io` supports only `enumerate`, `poll`, `recordVideo`, `screenshot`,
1528
- `screenConfig`. Routed through `idb ui button HOME`, like tap/swipe/type.
1599
+ - `io` supports only `enumerate`, `poll`, `recordVideo`, `screenshot`,
1600
+ `screenConfig`. Routed through `idb ui button HOME`, like tap/swipe/type.
1529
1601
  - **`android_set_locale` reported success unconditionally.** It broadcast the
1530
1602
  dead pre-Android-7 `SET_LOCALE` intent with stderr sent to `/dev/null`, and
1531
1603
  `am broadcast` exits 0 even when nothing handles the intent. Now uses the
@@ -1550,7 +1622,7 @@ because no failure ever carried `isError`, no host could tell.
1550
1622
 
1551
1623
  - **`sdk-floor` audit rule** (ITMS-90725), bringing `ios_app_store_audit` to
1552
1624
  **18 rules**. Asserts `DTSDKName` / `DTPlatformVersion` major >= 26 and
1553
- `DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 - a
1625
+ `DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 - a
1554
1626
  hard upload rejection that nothing checked. Grouped as `core` because it costs
1555
1627
  nothing to run. Reports a WARNING when an archive carries no build receipts at
1556
1628
  all, rather than passing silently.
package/index.js CHANGED
@@ -1589,15 +1589,82 @@ function fenceUntrusted(source, body) {
1589
1589
  return `${UNTRUSTED_OPEN} (${source}) <<<\n${body}\n${UNTRUSTED_CLOSE}`;
1590
1590
  }
1591
1591
 
1592
+ // Why an element that exists refused to be acted on. Returns null when nothing
1593
+ // obvious is wrong, so the caller still gets Playwright's own message rather
1594
+ // than a guess dressed up as a diagnosis.
1595
+ async function describeUnactionable(locator) {
1596
+ try {
1597
+ if ((await locator.count()) === 0) return "no element matches it any more";
1598
+ const state = await locator.evaluate((el) => {
1599
+ const cs = getComputedStyle(el);
1600
+ const r = el.getBoundingClientRect();
1601
+ return {
1602
+ display: cs.display,
1603
+ visibility: cs.visibility,
1604
+ opacity: cs.opacity,
1605
+ pointerEvents: cs.pointerEvents,
1606
+ w: r.width,
1607
+ h: r.height,
1608
+ offLeft: r.right <= 0,
1609
+ offTop: r.bottom <= 0,
1610
+ disabled: el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true",
1611
+ label: (el.innerText || el.getAttribute("aria-label") || "").trim().slice(0, 40),
1612
+ };
1613
+ });
1614
+ const named = state.label ? ` ("${state.label}")` : "";
1615
+ if (state.disabled) return `it is disabled${named}`;
1616
+ if (state.display === "none") return `it is display:none${named}`;
1617
+ if (state.visibility === "hidden") return `it is visibility:hidden${named}`;
1618
+ if (Number(state.opacity) === 0) return `it is fully transparent${named}`;
1619
+ if (state.w === 0 || state.h === 0) return `it has no size${named}`;
1620
+ if (state.offLeft || state.offTop) {
1621
+ return `it sits outside the viewport${named} - a skip link or an off-screen menu, visible only once focused`;
1622
+ }
1623
+ if (state.pointerEvents === "none") return `it ignores pointer events${named}`;
1624
+
1625
+ // Everything above says the element is there and paintable, which is where
1626
+ // Playwright's timeout usually comes from: it waits to deliver a pointer
1627
+ // event and something else is on top. Asking the document what is actually
1628
+ // at that point names the blocker instead of leaving the caller with a
1629
+ // duration.
1630
+ const covering = await locator.evaluate((el) => {
1631
+ const r = el.getBoundingClientRect();
1632
+ const x = r.left + r.width / 2;
1633
+ const y = r.top + r.height / 2;
1634
+ if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return "__outside__";
1635
+ const hit = document.elementFromPoint(x, y);
1636
+ if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
1637
+ const name = hit.tagName.toLowerCase();
1638
+ const id = hit.id ? `#${hit.id}` : "";
1639
+ const cls = hit.className && typeof hit.className === "string"
1640
+ ? `.${hit.className.trim().split(/\s+/).slice(0, 2).join(".")}`
1641
+ : "";
1642
+ return `${name}${id}${cls}`;
1643
+ });
1644
+ if (covering === "__outside__") {
1645
+ return `its centre is outside the viewport${named} - scroll to it, or pick a target that is on screen`;
1646
+ }
1647
+ if (covering) {
1648
+ return `${covering} is on top of it${named} - a sticky header or an overlay is taking the click`;
1649
+ }
1650
+ return null;
1651
+ } catch {
1652
+ return null;
1653
+ }
1654
+ }
1655
+
1592
1656
  async function extractReadable(page) {
1593
1657
  const paths = [];
1594
1658
  for (const mod of ["@mozilla/readability", "turndown"]) {
1595
1659
  try { paths.push(requireFrom.resolve(mod)); } catch { paths.push(null); }
1596
1660
  }
1597
- if (paths[0] && paths[1]) {
1661
+ const bundles = paths[0] && paths[1]
1662
+ ? [readabilityBundlePath(paths[0]), turndownBundlePath(paths[1])]
1663
+ : [null, null];
1664
+ if (bundles[0] && bundles[1]) {
1598
1665
  try {
1599
- await page.addScriptTag({ path: readabilityBundlePath(paths[0]) });
1600
- await page.addScriptTag({ path: turndownBundlePath(paths[1]) });
1666
+ await page.addScriptTag({ path: bundles[0] });
1667
+ await page.addScriptTag({ path: bundles[1] });
1601
1668
  const out = await page.evaluate(() => {
1602
1669
  const clone = document.cloneNode(true);
1603
1670
  // eslint-disable-next-line no-undef
@@ -1612,35 +1679,127 @@ async function extractReadable(page) {
1612
1679
  // fall through to the plain extraction
1613
1680
  }
1614
1681
  }
1682
+ // The fallback used to clone the body, strip chrome from the clone and read
1683
+ // its innerText. innerText is computed from layout, and a detached node has
1684
+ // none - so on a real page it returned a fraction of the text or nothing,
1685
+ // while a small fixture with everything above the fold looked fine.
1686
+ //
1687
+ // So: find the LIVE container that holds the article, read its innerText, and
1688
+ // drop chrome by line. Reading the live node is what makes the text real; the
1689
+ // line filter is what keeps "Jump to content" out of it.
1615
1690
  return page.evaluate(() => {
1616
- const drop = ["nav", "header", "footer", "aside", "script", "style", "noscript", "[role=navigation]", "[class*=cookie]", "[class*=banner]"];
1617
- const clone = document.body.cloneNode(true);
1618
- for (const sel of drop) for (const el of clone.querySelectorAll(sel)) el.remove();
1619
- const title = document.title || "";
1620
- const text = clone.innerText.replace(/\n{3,}/g, "\n\n").trim();
1621
- return { markdown: `# ${title}\n\n${text}`, text };
1691
+ const CONTAINERS = [
1692
+ "main article",
1693
+ "article",
1694
+ "main",
1695
+ "[role=main]",
1696
+ "#mw-content-text",
1697
+ "#content",
1698
+ ".markdown-body",
1699
+ ".post-content",
1700
+ ];
1701
+ let host = null;
1702
+ for (const sel of CONTAINERS) {
1703
+ const el = document.querySelector(sel);
1704
+ if (el && (el.innerText || "").trim().length > 200) {
1705
+ host = el;
1706
+ break;
1707
+ }
1708
+ }
1709
+ if (!host) host = document.body;
1710
+
1711
+ const CHROME = [
1712
+ /^jump to (content|navigation|search)$/i,
1713
+ /^skip to (main|content)/i,
1714
+ /^(main )?menu$/i,
1715
+ /^toggle the table of contents$/i,
1716
+ /^(search|sign in|log in|sign up|subscribe|donate)$/i,
1717
+ /^(accept|reject) (all )?cookies?/i,
1718
+ /^cookie (settings|preferences)/i,
1719
+ /^edit (source|this page)$/i,
1720
+ /^\[edit\]$/i,
1721
+ /^(privacy policy|terms of use|contact us)$/i,
1722
+ ];
1723
+ const kept = (host.innerText || "")
1724
+ .split("\n")
1725
+ .filter((line) => {
1726
+ const t = line.trim();
1727
+ if (!t) return true;
1728
+ return !CHROME.some((rx) => rx.test(t));
1729
+ })
1730
+ .join("\n")
1731
+ .replace(/\n{3,}/g, "\n\n")
1732
+ .trim();
1733
+
1734
+ // document.title carries the site suffix ("Bloom filter - Wikipedia").
1735
+ // The first h1 inside the container is the article's own name.
1736
+ const h1 = host.querySelector("h1") || document.querySelector("h1");
1737
+ const title = (h1 && h1.innerText.trim()) || document.title || "";
1738
+ return { markdown: `# ${title}\n\n${kept}`, text: kept };
1622
1739
  });
1623
1740
  }
1624
1741
 
1742
+ // Both packages ship a Node entry point and a browser bundle, and only the
1743
+ // browser bundle can be injected into a page: the Node entry is CommonJS, so
1744
+ // addScriptTag loads it, `module` is undefined, and the whole extraction falls
1745
+ // through to the plain path without ever saying why.
1746
+ //
1747
+ // The file is checked rather than assumed. A layout change upstream returns
1748
+ // null here, which keeps the fallback rather than injecting a file that throws.
1625
1749
  function readabilityBundlePath(resolved) {
1626
- return resolved.replace(/index\.js$/, "Readability.js");
1750
+ const candidate = join(dirname(resolved), "Readability.js");
1751
+ return existsSync(candidate) ? candidate : null;
1627
1752
  }
1628
1753
  function turndownBundlePath(resolved) {
1629
- return resolved.replace(/turndown\.cjs$|index\.js$/, "turndown.js");
1754
+ for (const name of ["turndown.browser.umd.js", "turndown.umd.js", "turndown.js"]) {
1755
+ const candidate = join(dirname(resolved), name);
1756
+ if (existsSync(candidate)) return candidate;
1757
+ }
1758
+ return null;
1759
+ }
1760
+
1761
+ // Every tool below reads the page that is already open. A session that has not
1762
+ // navigated yet is sitting on `about:blank`, where extraction succeeds and
1763
+ // returns nothing: the crawl reports "1 page", the index gains an empty
1764
+ // document, and the search that follows ranks it. Name the missing step
1765
+ // instead of returning an empty success.
1766
+ function blankPageError(page, tool) {
1767
+ const url = page.url();
1768
+ if (url && url !== "about:blank" && !url.startsWith("chrome-error://")) return null;
1769
+ return `ERROR: ${tool} reads the page that is currently open, and nothing has been opened yet (${url || "no url"}). Call web_goto first.`;
1630
1770
  }
1631
1771
 
1632
1772
  async function sameOriginLinks(page) {
1633
1773
  return page.evaluate(() => {
1634
1774
  const here = location.origin;
1635
- const out = new Set();
1636
- for (const a of document.querySelectorAll("a[href]")) {
1637
- let u;
1638
- try { u = new URL(a.getAttribute("href"), location.href); } catch { continue; }
1639
- if (u.origin !== here) continue;
1640
- u.hash = "";
1641
- out.add(u.toString());
1642
- }
1643
- return [...out];
1775
+ // A documentation page carries two kinds of link: the ones in its body,
1776
+ // which are the material, and the site chrome - a global index, a
1777
+ // "report a bug" form, the breadcrumb back to the root - which is the
1778
+ // same handful of pages on every page of the site. A bounded crawl that
1779
+ // takes them in DOM order spends a third of its budget on chrome before
1780
+ // it reaches the second body link, so body links are handed back first.
1781
+ const CONTENT = ["main article", "article", "main", "[role=main]", "#content", ".markdown-body", ".body"];
1782
+ let host = null;
1783
+ for (const sel of CONTENT) {
1784
+ const el = document.querySelector(sel);
1785
+ if (el && el.querySelector("a[href]")) { host = el; break; }
1786
+ }
1787
+ const collect = (root) => {
1788
+ const found = [];
1789
+ for (const a of root.querySelectorAll("a[href]")) {
1790
+ let u;
1791
+ try { u = new URL(a.getAttribute("href"), location.href); } catch { continue; }
1792
+ if (u.origin !== here) continue;
1793
+ // A link that differs from another only by its query string is the
1794
+ // same document with a feedback form or a tracking tag attached.
1795
+ if (u.search && /^(https?:)?[^?]*\.(html?|md|txt)$/i.test(u.pathname)) u.search = "";
1796
+ u.hash = "";
1797
+ found.push(u.toString());
1798
+ }
1799
+ return found;
1800
+ };
1801
+ const ordered = host ? [...collect(host), ...collect(document)] : collect(document);
1802
+ return [...new Set(ordered)];
1644
1803
  });
1645
1804
  }
1646
1805
 
@@ -1724,7 +1883,7 @@ const WEB_TOOLS = [
1724
1883
  { name: "web_storage_state", description: "Read cookies and localStorage for the current page, or restore a previously read state.", inputSchema: { type: "object", properties: { restore: { type: "string", description: "A state JSON string from a previous call. Omit to read." } } } },
1725
1884
  { name: "web_extract", description: "The page's article content as Markdown, with navigation, footers and cookie banners dropped. Falls back to a plain text extraction when the readability helpers are not installed.", inputSchema: { type: "object", properties: { as: { type: "string", enum: ["markdown", "text"], description: "Default markdown." } } } },
1726
1885
  { name: "web_map", description: "Same-origin links reachable from the current page, deduplicated. A cheap site map without fetching anything.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Default 200." } } } },
1727
- { name: "web_crawl", description: "Follow same-origin links from the current page, extracting each one. Bounded: max_pages default 20 (cap 200), max_depth default 2, one request at a time with a delay, robots.txt respected, and a self-identifying User-Agent.", inputSchema: { type: "object", properties: { max_pages: { type: "number" }, max_depth: { type: "number" }, delay_ms: { type: "number", description: "Default 250." }, respect_robots: { type: "boolean", description: "Default true." } } } },
1886
+ { name: "web_crawl", description: "Follow same-origin links from the current page, extracting each one and indexing it so context_search can rank the crawl afterwards. Bounded: max_pages default 20 (cap 200), max_depth default 2, one request at a time with a delay, robots.txt respected, and a self-identifying User-Agent.", inputSchema: { type: "object", properties: { max_pages: { type: "number" }, max_depth: { type: "number" }, delay_ms: { type: "number", description: "Default 250." }, index: { type: "boolean", description: "Index each page into the full-text index. Default true - a crawl whose pages cannot be searched afterwards returns a wall of text nobody can query." }, respect_robots: { type: "boolean", description: "Default true." } } } },
1728
1887
  { name: "web_press_key", description: "Press a key, optionally focusing an element first.", inputSchema: { type: "object", properties: { key: { type: "string", description: "Playwright key name: Enter, Escape, Tab, ArrowDown, Control+A ..." }, selector: { type: "string" }, ref: { type: "string" } }, required: ["key"] } },
1729
1888
  { name: "web_select_option", description: "Choose an option in a <select>, by value or by visible label.", inputSchema: { type: "object", properties: { selector: { type: "string" }, ref: { type: "string" }, value: { type: "string" }, label: { type: "string" } } } },
1730
1889
  ];
@@ -1768,7 +1927,21 @@ async function handleWeb(name, args) {
1768
1927
  case "web_click": {
1769
1928
  const t = locate(page, args);
1770
1929
  if (t.error) return t.error;
1771
- await t.locator.first().click({ timeout: args.timeout_ms || 5000 });
1930
+ try {
1931
+ await t.locator.first().click({ timeout: args.timeout_ms || 5000 });
1932
+ } catch (e) {
1933
+ // Playwright waits for the element to be actionable and then reports a
1934
+ // timeout, which says nothing about WHY. The commonest reason on a real
1935
+ // page is a target the snapshot listed and the layout hides: the first
1936
+ // link on an accessibility-first site is a "Skip to content" link
1937
+ // positioned off-screen until it takes focus. A caller told only
1938
+ // "Timeout 5000ms exceeded" re-tries the same ref; one told the element
1939
+ // is hidden picks a different one.
1940
+ const why = await describeUnactionable(t.locator.first());
1941
+ return why
1942
+ ? `${ERROR_PREFIX}${t.label} could not be clicked: ${why}`
1943
+ : `${ERROR_PREFIX}${t.label} could not be clicked: ${String(e.message || e).split("\n")[0]}`;
1944
+ }
1772
1945
  return `Clicked: ${t.label}`;
1773
1946
  }
1774
1947
  case "web_type": {
@@ -1799,7 +1972,14 @@ async function handleWeb(name, args) {
1799
1972
  const tree = await target.ariaSnapshot({ mode: "ai" });
1800
1973
  // Register every ref the snapshot handed out, stamped with this
1801
1974
  // snapshot's id so a later navigation invalidates them as a set.
1802
- for (const m of String(tree).matchAll(/\[ref=(e\d+)\]/g)) {
1975
+ //
1976
+ // An element inside an iframe is handed out as `f<frame>e<n>`, not `e<n>`.
1977
+ // Matching only the bare form registers nothing on a page that frames
1978
+ // anything - a cookie banner, an embedded video, an analytics pixel - so
1979
+ // every ref on it is refused as unknown while the snapshot plainly shows
1980
+ // it. Playwright resolves both spellings through the same `aria-ref=`
1981
+ // engine; only this registry needed to accept them.
1982
+ for (const m of String(tree).matchAll(/\[ref=((?:f\d+)?e\d+)\]/g)) {
1803
1983
  _refs.set(m[1], { snapshot: _snapshotId });
1804
1984
  }
1805
1985
  return `${_refs.size} refs\n${tree}`;
@@ -1864,24 +2044,43 @@ async function handleWeb(name, args) {
1864
2044
  }
1865
2045
 
1866
2046
  case "web_extract": {
2047
+ const blank = blankPageError(page, "web_extract");
2048
+ if (blank) return blank;
1867
2049
  const text = await extractReadable(page);
1868
2050
  const body = args.as === "text" ? text.text : text.markdown;
1869
2051
  return fenceUntrusted(page.url(), body);
1870
2052
  }
1871
2053
 
1872
2054
  case "web_map": {
2055
+ const blank = blankPageError(page, "web_map");
2056
+ if (blank) return blank;
1873
2057
  const links = await sameOriginLinks(page);
1874
2058
  const limit = args.limit || 200;
1875
2059
  return links.slice(0, limit).join("\n") || "(no same-origin links on this page)";
1876
2060
  }
1877
2061
 
1878
2062
  case "web_crawl": {
2063
+ const blank = blankPageError(page, "web_crawl");
2064
+ if (blank) return blank;
1879
2065
  const maxPages = Math.min(Number(args.max_pages) || 20, 200);
1880
2066
  const maxDepth = Number(args.max_depth) || 2;
1881
2067
  const delay = Number(args.delay_ms) || 250;
1882
2068
  const respectRobots = args.respect_robots !== false;
1883
2069
  const start = page.url();
1884
2070
  const origin = new URL(start).origin;
2071
+ const wantIndex = args.index !== false;
2072
+ let indexed = 0;
2073
+ let indexDb = null;
2074
+ let crawlDir = null;
2075
+ if (wantIndex) {
2076
+ try {
2077
+ indexDb = ctxIndex.openIndex();
2078
+ crawlDir = join(ctxIndex.INDEX_DIR, "crawl");
2079
+ if (!existsSync(crawlDir)) mkdirSync(crawlDir, { recursive: true });
2080
+ } catch {
2081
+ indexDb = null;
2082
+ }
2083
+ }
1885
2084
  const disallowed = respectRobots ? await robotsDisallow(page, origin) : [];
1886
2085
  const seen = new Set([start]);
1887
2086
  const queue = [{ url: start, depth: 0 }];
@@ -1897,6 +2096,23 @@ async function handleWeb(name, args) {
1897
2096
  }
1898
2097
  const doc = await extractReadable(page);
1899
2098
  out.push(`--- ${url}\n${doc.markdown}`);
2099
+ // A crawl that cannot be searched afterwards is a wall of text: twenty
2100
+ // pages arrive at once and the caller has no way to ask which of them
2101
+ // answers the question. Each page is written beside the index and
2102
+ // indexed under its URL, so context_search ranks the crawl the same way
2103
+ // it ranks an offloaded payload. Failure here never fails the crawl -
2104
+ // the pages are already in the reply.
2105
+ if (wantIndex && indexDb) {
2106
+ try {
2107
+ const slug = url.replace(/[^a-z0-9]+/gi, "-").slice(0, 120);
2108
+ const file = join(crawlDir, `${slug || "page"}.md`);
2109
+ writeFileSync(file, `# ${url}\n\n${doc.markdown}\n`);
2110
+ ctxIndex.indexFile(indexDb, file);
2111
+ indexed += 1;
2112
+ } catch {
2113
+ /* the page is in the reply; the index is the bonus */
2114
+ }
2115
+ }
1900
2116
  if (depth < maxDepth) {
1901
2117
  for (const link of await sameOriginLinks(page)) {
1902
2118
  if (seen.has(link) || seen.size >= maxPages * 4) continue;
@@ -1909,7 +2125,12 @@ async function handleWeb(name, args) {
1909
2125
  if (queue.length && out.length < maxPages) await page.waitForTimeout(delay);
1910
2126
  }
1911
2127
  resetRefs();
1912
- return `${out.length} page(s)\n\n${fenceUntrusted(`${out.length} page(s) from ${origin}`, out.join("\n\n"))}`;
2128
+ const indexNote = indexed
2129
+ ? ` - ${indexed} indexed, searchable with context_search`
2130
+ : wantIndex && indexDb === null
2131
+ ? " - the index could not be opened, so these pages are not searchable"
2132
+ : "";
2133
+ return `${out.length} page(s)${indexNote}\n\n${fenceUntrusted(`${out.length} page(s) from ${origin && origin !== "null" ? origin : start}`, out.join("\n\n"))}`;
1913
2134
  }
1914
2135
 
1915
2136
  case "web_press_key": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.13.0",
3
+ "version": "3.13.1",
4
4
  "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 115 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -85,11 +85,24 @@
85
85
  "pngjs": "^7.0.0"
86
86
  },
87
87
  "peerDependencies": {
88
- "playwright": ">=1.60.0 <2"
88
+ "@mozilla/readability": "^0.6.0",
89
+ "playwright": ">=1.60.0 <2",
90
+ "turndown": "^7.2.0"
89
91
  },
90
92
  "peerDependenciesMeta": {
91
93
  "playwright": {
92
94
  "optional": true
95
+ },
96
+ "@mozilla/readability": {
97
+ "optional": true
98
+ },
99
+ "turndown": {
100
+ "optional": true
93
101
  }
102
+ },
103
+ "devDependencies": {
104
+ "@mozilla/readability": "^0.6.0",
105
+ "playwright": "^1.63.0",
106
+ "turndown": "^7.2.4"
94
107
  }
95
108
  }
@@ -108,30 +108,47 @@ export function indexFile(db, path) {
108
108
  // a log line into the query gets a SQL error instead of results. Quoting each
109
109
  // term makes every query a literal phrase search, which is what a caller
110
110
  // searching a log actually means.
111
- export function toMatchQuery(query) {
111
+ //
112
+ // Space between two FTS5 terms is AND. That is right for a log line, where
113
+ // every word is a fact about the one entry being looked for, and wrong for a
114
+ // question: "pack numbers into a bytes object using a format string" demands
115
+ // all nine words from a single chunk and returns nothing at all rather than
116
+ // the page that carries seven of them. `mode: "or"` builds the same query
117
+ // with OR, which is what `search` falls back to; bm25 then ranks by how many
118
+ // of the terms a chunk carries and how rare each one is.
119
+ export function toMatchQuery(query, { mode = "and" } = {}) {
112
120
  const terms = String(query)
113
121
  .split(/\s+/)
114
122
  .map((t) => t.replace(/"/g, ""))
115
123
  .filter(Boolean);
116
124
  if (!terms.length) return null;
117
- return terms.map((t) => `"${t}"`).join(" ");
125
+ return terms.map((t) => `"${t}"`).join(mode === "or" ? " OR " : " ");
118
126
  }
119
127
 
120
128
  export function search(db, query, { limit = 5, path = null } = {}) {
121
- const match = toMatchQuery(query);
122
- if (!match) return [];
123
129
  const where = path
124
130
  ? "where chunks match ? and doc_id = (select id from docs where path = ?)"
125
131
  : "where chunks match ?";
126
- const params = path ? [match, path] : [match];
127
- const rows = db
128
- .prepare(
129
- `select chunks.rowid, doc_id, first_line, last_line, body,
130
- (select path from docs where docs.id = chunks.doc_id) as path,
131
- bm25(chunks) as score
132
- from chunks ${where} order by score limit ?`,
133
- )
134
- .all(...params, limit);
132
+ const sql = db.prepare(
133
+ `select chunks.rowid, doc_id, first_line, last_line, body,
134
+ (select path from docs where docs.id = chunks.doc_id) as path,
135
+ bm25(chunks) as score
136
+ from chunks ${where} order by score limit ?`,
137
+ );
138
+ const run = (match) => {
139
+ const params = path ? [match, path] : [match];
140
+ return sql.all(...params, limit);
141
+ };
142
+ // Every term first, because a caller who typed all of them meant all of
143
+ // them. Any term second, so a question that no single chunk answers word
144
+ // for word still comes back ranked instead of empty.
145
+ let rows = [];
146
+ for (const mode of ["and", "or"]) {
147
+ const match = toMatchQuery(query, { mode });
148
+ if (!match) return [];
149
+ rows = run(match);
150
+ if (rows.length) break;
151
+ }
135
152
  return rows.map((r) => ({
136
153
  id: r.rowid,
137
154
  docId: r.doc_id,
@@ -142,6 +142,60 @@ export function offloadedErrorSummary(text, path, headChars = 400, tailChars = 2
142
142
  );
143
143
  }
144
144
 
145
+ // What a payload IS decides what is worth keeping out of it.
146
+ //
147
+ // A head-plus-tail window keeps the two places a large payload is least likely
148
+ // to carry its answer. A logcat's meaning is one FATAL line somewhere in the
149
+ // middle; a build log's is its `error:` lines; a UI dump's is the handful of
150
+ // nodes you can actually tap. The tool name is already here, so the window can
151
+ // be chosen rather than fixed - and the lines it surfaces come with their line
152
+ // numbers, so the caller can go straight to them in the saved file.
153
+ //
154
+ // Patterns only, no model: the cost of being wrong is a few extra lines in a
155
+ // window that already exists, which is the right trade for a dependency-free
156
+ // check that runs on every offload.
157
+ const SIGNAL_FAMILIES = [
158
+ {
159
+ when: /logcat|crash|crashes/i,
160
+ label: "crash",
161
+ rx: /\b(FATAL|AndroidRuntime|Exception|SIGABRT|SIGSEGV|SIGILL|Thread \d+ Crashed|Abort trap)\b/,
162
+ },
163
+ {
164
+ when: /xcodebuild|xcresult|build|test/i,
165
+ label: "failure",
166
+ rx: /(^|\s)(error:|FAILED|failed:|XCTAssert\w*|Undefined symbol|linker command failed|\*\* BUILD FAILED)/,
167
+ },
168
+ {
169
+ when: /ui_tree|accessibility|inspect/i,
170
+ label: "interactive",
171
+ rx: /\b(AXButton|AXTextField|AXSwitch|clickable=true|enabled=true|focusable=true)\b/,
172
+ },
173
+ { when: /leaks|meminfo|memory/i, label: "measurement", rx: /\b(Leak|leaked|TOTAL|Total PSS|bytes)\b/ },
174
+ ];
175
+
176
+ export const SIGNAL_LINE_CAP = 20;
177
+ const SIGNAL_LINE_CHARS = 200;
178
+
179
+ // Lines from the part the window hides, that carry what this kind of payload is
180
+ // read for. Returns "" when the family is unknown or nothing matched, so the
181
+ // window is exactly what it was before.
182
+ export function signalLines(tool, lines, headCount, tailCount, cap = SIGNAL_LINE_CAP) {
183
+ const family = SIGNAL_FAMILIES.find((f) => f.when.test(String(tool)));
184
+ if (!family) return "";
185
+ const from = headCount;
186
+ const to = Math.max(headCount, lines.length - tailCount);
187
+ const hits = [];
188
+ for (let i = from; i < to && hits.length < cap; i++) {
189
+ if (family.rx.test(lines[i])) {
190
+ const body = lines[i].length > SIGNAL_LINE_CHARS ? `${lines[i].slice(0, SIGNAL_LINE_CHARS)}...` : lines[i];
191
+ hits.push(` ${i + 1}: ${body}`);
192
+ }
193
+ }
194
+ if (!hits.length) return "";
195
+ const more = hits.length === cap ? ` (first ${cap})` : "";
196
+ return `\n\n[${family.label} lines from the hidden middle${more}]\n${hits.join("\n")}`;
197
+ }
198
+
145
199
  export function offloadLargeText(tool, text, opts = {}) {
146
200
  const dir = opts.dir ?? OFFLOAD_DIR;
147
201
  const minChars = opts.minChars ?? OFFLOAD_MIN_CHARS;
@@ -166,13 +220,15 @@ export function offloadLargeText(tool, text, opts = {}) {
166
220
  const head = lines.slice(0, HEAD_LINES).join("\n");
167
221
  const tail = lines.slice(-TAIL_LINES).join("\n");
168
222
  const hidden = Math.max(0, lines.length - HEAD_LINES - TAIL_LINES);
223
+ const signal = signalLines(tool, lines, HEAD_LINES, TAIL_LINES);
169
224
  return {
170
225
  offloaded: true,
171
226
  path,
172
227
  text:
173
228
  `${head}\n\n[... ${hidden} line(s) not shown. Full output (${lines.length} lines, ` +
174
229
  `${text.length} bytes) saved to ${path} - search it with ` +
175
- `agent_query_output {pattern: "..."} instead of re-running this tool ...]\n\n${tail}`,
230
+ `agent_query_output {pattern: "..."} instead of re-running this tool ...]` +
231
+ `${signal}\n\n${tail}`,
176
232
  };
177
233
  }
178
234