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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -1250,15 +1320,30 @@ paths and requires an error on each. Gate 9 requires every tool advertising an
1250
1320
  outputSchema to actually return `structuredContent`, and to keep its text
1251
1321
  content beside it. 14 gates, all green.
1252
1322
 
1323
+ ## [3.14.0] - 2026-09-21
1324
+
1325
+ ### Added
1326
+
1327
+ - **`security` family: `security_cvss_score` and `security_dep_inventory`.**
1328
+ security_cvss_score turns a CVSS 3.1 base vector into a score and band by the
1329
+ FIRST arithmetic, so a finding's number cannot drift from its vector.
1330
+ security_dep_inventory normalizes a lockfile (npm, yarn, pnpm, CocoaPods, pip,
1331
+ Go, Cargo, RubyGems) into {ecosystem, name, version}. Both are pure JS and
1332
+ contact nothing - matching an inventory against known CVEs stays a separate,
1333
+ network-bearing step, so the stdio-only egress surface is unchanged. Gated at
1334
+ serve time by `MCP_TOOLKIT_CAPS=security`. 117 tools total.
1335
+
1253
1336
  ## [3.0.0] - 2026-08-22
1254
1337
 
1255
1338
  ### Changed
1339
+
1256
1340
  - **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
1341
  - **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
1342
  - **`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
1343
  - 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
1344
 
1261
1345
  ### Migration
1346
+
1262
1347
  - `npm i -g @mmerterden/multi-agent-toolkit-mcp` then remove the old registration: `claude mcp remove dev-toolkit` (same for `copilot` / `codex`).
1263
1348
  - `@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
1349
 
@@ -1480,7 +1565,7 @@ rarely the same size.
1480
1565
  size finding that buried the real defects. The comparison now reports
1481
1566
  per-element edge insets (left/right) rather than raw width, gaps between
1482
1567
  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
1568
+ colour sampled from the text ink rather than the box average - the box average
1484
1569
  previously returned the background colour and so passed every check. Pass
1485
1570
  `responsive: false` to restore the previous absolute-delta behaviour.
1486
1571
  - **Height is advisory.** It is still reported, but a content-driven height
@@ -1512,20 +1597,20 @@ because no failure ever carried `isError`, no host could tell.
1512
1597
  ### Fixed
1513
1598
 
1514
1599
  - **`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
1600
+ failures as ordinary text, so a host - and the pipeline gates reading these
1601
+ results - saw failure as success. Failures now return `isError: true`. Failing
1517
1602
  command output is also capped at 600 chars: a failed `simctl` call was
1518
1603
  inlining its entire ~3 KB usage page into the caller's context.
1519
1604
  - **`ios_biometric` was a no-op that claimed success.** `simctl keychain
1520
- <device> biometric-enroll` / `biometric-match` do not exist - `keychain`
1605
+ <device> biometric-enroll` / `biometric-match` do not exist - `keychain`
1521
1606
  supports only `add-root-cert`, `add-cert`, `reset`. Now drives the BiometricKit
1522
1607
  notification via `notifyutil` inside the simulator, and because `notifyutil`
1523
1608
  exits 0 even when it cannot post the name, its output is inspected: a "Failed
1524
1609
  with code N" line is reported as a failure with next steps instead of
1525
1610
  "success simulated".
1526
1611
  - **`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.
1612
+ - `io` supports only `enumerate`, `poll`, `recordVideo`, `screenshot`,
1613
+ `screenConfig`. Routed through `idb ui button HOME`, like tap/swipe/type.
1529
1614
  - **`android_set_locale` reported success unconditionally.** It broadcast the
1530
1615
  dead pre-Android-7 `SET_LOCALE` intent with stderr sent to `/dev/null`, and
1531
1616
  `am broadcast` exits 0 even when nothing handles the intent. Now uses the
@@ -1550,7 +1635,7 @@ because no failure ever carried `isError`, no host could tell.
1550
1635
 
1551
1636
  - **`sdk-floor` audit rule** (ITMS-90725), bringing `ios_app_store_audit` to
1552
1637
  **18 rules**. Asserts `DTSDKName` / `DTPlatformVersion` major >= 26 and
1553
- `DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 - a
1638
+ `DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 - a
1554
1639
  hard upload rejection that nothing checked. Grouped as `core` because it costs
1555
1640
  nothing to run. Reports a WARNING when an archive carries no build receipts at
1556
1641
  all, rather than passing silently.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  🇹🇷 Türkçe: [README.tr.md](./README.tr.md)
9
9
 
10
- **115 tools** for iOS Simulator, Android Emulator, and headless web control. MCP server that lets your AI coding assistant see, interact with, and audit your mobile apps - plus drive browsers, run an 18-rule App Store compliance audit, and orchestrate multi-step batch flows.
10
+ **117 tools** for iOS Simulator, Android Emulator, and headless web control. MCP server that lets your AI coding assistant see, interact with, and audit your mobile apps - plus drive browsers, run an 18-rule App Store compliance audit, and orchestrate multi-step batch flows.
11
11
 
12
12
  Distributed on the **public npm registry** - `npx @mmerterden/multi-agent-toolkit-mcp` resolves with no auth, no token, no `~/.npmrc` setup.
13
13
 
@@ -15,7 +15,7 @@ Distributed on the **public npm registry** - `npx @mmerterden/multi-agent-toolki
15
15
 
16
16
  That distinction is worth keeping straight. This line once called five hosts "the multi-agent-pipeline's full-orchestration targets", which stopped being true in pipeline v10.7.0 when the Cursor / Antigravity / Codex / Copilot Chat adapters were deleted. Codex CLI returned as a first-class target in pipeline v13.0.0 on capability - it now has skills, parallel sub-agents, hooks and MCP - and it is on the verified list only because a gate exercises it, not because it is supported elsewhere. Claiming hosts nobody exercises is not free either: 2.20.0 and 2.21.0 shipped an illegal `outputSchema` on the first tool, so Claude Code rejected the whole `tools/list` and served zero of the 78 - in the primary host, for two releases, with every gate in this repo green.
17
17
 
18
- **13 categories:**
18
+ **14 categories:**
19
19
  - **Device Control** (59 tools) - screenshot, tap, swipe, type, navigate, dark mode, locale, orientation, location, permissions, push notifications, real start/stop screen recording
20
20
  - **Memory** (2 tools) - `ios_leaks` runs /usr/bin/leaks against a simulator or host process, snapshot or diff against a saved memory graph; `android_meminfo` reads dumpsys meminfo, snapshot or diff. Both report `measurable:false` rather than a clean result when they could not look
21
21
  - **Crash Diagnostics** (2 tools) - `ios_list_crashes` reads the host's DiagnosticReports, `android_list_crashes` dumps the adb crash buffer, both tail-bounded
@@ -29,6 +29,7 @@ That distinction is worth keeping straight. This line once called five hosts "th
29
29
  - **Research** (2 tools) - provider-backed web search normalized to {title, url, snippet}, and a cited answer through Perplexity Sonar. Keys are read from the environment at call time and go straight into a header; a literal key is not an accepted argument, the same contract the Wallet signer uses
30
30
  - **Media** (1 tool) - `media_frames` pulls key frames out of a recording with ffmpeg, dropping near-duplicates, so the video `ios_record_video` just produced becomes something a model can actually look at
31
31
  - **Autonomous Agent DSL** (2 tools) - `agent_run_steps` executes a batch array of {tool, args, continue_on_error?, wait_ms?} steps in one MCP round trip. Ideal for scripted login flows, form fills, multi-step QA paths. `agent_query_output` searches the full output of an earlier call that was too large to return inline, so a follow-up question does not mean re-running an expensive tool.
32
+ - **Security** (2 tools) - offline, no egress: `security_cvss_score` turns a CVSS 3.1 base vector into a score and band by the FIRST arithmetic, so a finding's number cannot drift from its vector; `security_dep_inventory` normalizes a lockfile (npm, yarn, pnpm, CocoaPods, pip, Go, Cargo, RubyGems) into `{ecosystem, name, version}` for a separate CVE lookup to consume. Neither contacts a registry
32
33
 
33
34
  ## Quick Start
34
35
 
package/README.tr.md CHANGED
@@ -15,7 +15,7 @@ iOS Simulator, Android Emulator ve headless web kontrolü için **115 araç**. A
15
15
 
16
16
  Bu ayrımı net tutmakta fayda var. Bu satır bir zamanlar beş host'u "multi-agent-pipeline'ın tam-orkestrasyon hedefleri" olarak adlandırıyordu, bu da pipeline v10.7.0'da Cursor / Antigravity / Codex / Copilot Chat adaptörleri silindiğinde doğru olmaktan çıktı. Codex CLI, pipeline v13.0.0'da yetenek üzerinden birinci-sınıf bir hedef olarak geri döndü - artık skill'leri, paralel sub-agent'ları, hook'ları ve MCP'si var - ve doğrulanmış listede sadece bir kapı onu çalıştırdığı için yer alıyor, başka bir yerde desteklendiği için değil. Kimsenin çalıştırmadığı host'ları iddia etmek de bedavaya gelmiyor: 2.20.0 ve 2.21.0, ilk araçta yasa dışı bir `outputSchema` gönderdi, bu yüzden Claude Code tüm `tools/list`'i reddetti ve 78'in sıfırını sundu - birincil host'ta, iki release boyunca, bu repo'daki her kapı yeşilken.
17
17
 
18
- **13 kategori:**
18
+ **14 kategori:**
19
19
  - **Device Control** (59 araç) - screenshot, tap, swipe, type, navigate, dark mode, locale, orientation, location, permissions, push notifications, gerçek start/stop ekran kaydı
20
20
  - **Memory** (2 araç) - `ios_leaks`, bir simülatör ya da host süreci üzerinde /usr/bin/leaks çalıştırır; anlık görüntü alır ya da kayıtlı bir memory graph ile karşılaştırır. `android_meminfo`, dumpsys meminfo okur, aynı şekilde anlık görüntü ya da fark. İkisi de bakamadıklarında temiz bir sonuç değil `measurable:false` döner
21
21
  - **Crash Diagnostics** (2 araç) - `ios_list_crashes` host'un DiagnosticReports dizinini okur, `android_list_crashes` adb crash buffer'ını döker, ikisi de tail-sınırlı
@@ -29,6 +29,7 @@ Bu ayrımı net tutmakta fayda var. Bu satır bir zamanlar beş host'u "multi-ag
29
29
  - **Research** (2 araç) - sağlayıcı destekli web araması, {title, url, snippet} şemasına normalize edilmiş; ve Perplexity Sonar üzerinden atıflı cevap. Anahtarlar çağrı anında ortamdan okunup doğrudan bir başlığa gider; düz metin anahtar kabul edilen bir argüman değil - Wallet imzalayıcısının kullandığı sözleşmenin aynısı
30
30
  - **Media** (1 araç) - `media_frames`, bir kaydın anahtar karelerini ffmpeg ile çıkarır ve birbirine benzeyenleri eler; böylece `ios_record_video`'nun ürettiği video bir modelin gerçekten bakabileceği bir şeye dönüşür
31
31
  - **Autonomous Agent DSL** (2 araç) - `agent_run_steps`, tek bir MCP round trip'inde {tool, args, continue_on_error?, wait_ms?} adımlarından oluşan bir batch dizisini çalıştırır. Scriptlenmiş login akışları, form doldurma, çok-adımlı QA yolları için ideal. `agent_query_output`, satır içi dönemeyecek kadar büyük olan önceki bir çağrının tam çıktısını arar; böylece bir takip sorusu pahalı aracı yeniden koşturmak anlamına gelmez.
32
+ - **Security** (2 araç) - çevrimdışı, egress yok: `security_cvss_score` bir CVSS 3.1 temel vektörünü FIRST aritmetiğiyle skora ve banda çevirir, böylece bir bulgunun sayısı vektöründen sapamaz; `security_dep_inventory` bir lockfile'ı (npm, yarn, pnpm, CocoaPods, pip, Go, Cargo, RubyGems) `{ecosystem, name, version}` listesine normalize eder, ayrı bir CVE aramasının tüketmesi için. İkisi de bir registry'ye bağlanmaz
32
33
 
33
34
  ## Hızlı Başlangıç
34
35
 
package/index.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  shutdownAllLsp,
47
47
  } from "./tools/code-intel/index.js";
48
48
  import { PASS_TOOLS, handlePass, PASS_READ_ONLY, PASS_OUTPUT_SCHEMAS } from "./tools/pass-kit/index.js";
49
+ import { SECURITY_TOOLS, handleSecurity, SECURITY_OUTPUT_SCHEMAS } from "./tools/security/index.js";
49
50
  import { parseLaunchOutput } from "./tools/launch-time/index.js";
50
51
  import { parseLeaksOutput, parseMeminfoOutput, diffMeminfo } from "./tools/memory/index.js";
51
52
  import { auditIosTree, auditAndroidDump, parseAuditResults } from "./tools/a11y/index.js";
@@ -1589,15 +1590,82 @@ function fenceUntrusted(source, body) {
1589
1590
  return `${UNTRUSTED_OPEN} (${source}) <<<\n${body}\n${UNTRUSTED_CLOSE}`;
1590
1591
  }
1591
1592
 
1593
+ // Why an element that exists refused to be acted on. Returns null when nothing
1594
+ // obvious is wrong, so the caller still gets Playwright's own message rather
1595
+ // than a guess dressed up as a diagnosis.
1596
+ async function describeUnactionable(locator) {
1597
+ try {
1598
+ if ((await locator.count()) === 0) return "no element matches it any more";
1599
+ const state = await locator.evaluate((el) => {
1600
+ const cs = getComputedStyle(el);
1601
+ const r = el.getBoundingClientRect();
1602
+ return {
1603
+ display: cs.display,
1604
+ visibility: cs.visibility,
1605
+ opacity: cs.opacity,
1606
+ pointerEvents: cs.pointerEvents,
1607
+ w: r.width,
1608
+ h: r.height,
1609
+ offLeft: r.right <= 0,
1610
+ offTop: r.bottom <= 0,
1611
+ disabled: el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true",
1612
+ label: (el.innerText || el.getAttribute("aria-label") || "").trim().slice(0, 40),
1613
+ };
1614
+ });
1615
+ const named = state.label ? ` ("${state.label}")` : "";
1616
+ if (state.disabled) return `it is disabled${named}`;
1617
+ if (state.display === "none") return `it is display:none${named}`;
1618
+ if (state.visibility === "hidden") return `it is visibility:hidden${named}`;
1619
+ if (Number(state.opacity) === 0) return `it is fully transparent${named}`;
1620
+ if (state.w === 0 || state.h === 0) return `it has no size${named}`;
1621
+ if (state.offLeft || state.offTop) {
1622
+ return `it sits outside the viewport${named} - a skip link or an off-screen menu, visible only once focused`;
1623
+ }
1624
+ if (state.pointerEvents === "none") return `it ignores pointer events${named}`;
1625
+
1626
+ // Everything above says the element is there and paintable, which is where
1627
+ // Playwright's timeout usually comes from: it waits to deliver a pointer
1628
+ // event and something else is on top. Asking the document what is actually
1629
+ // at that point names the blocker instead of leaving the caller with a
1630
+ // duration.
1631
+ const covering = await locator.evaluate((el) => {
1632
+ const r = el.getBoundingClientRect();
1633
+ const x = r.left + r.width / 2;
1634
+ const y = r.top + r.height / 2;
1635
+ if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return "__outside__";
1636
+ const hit = document.elementFromPoint(x, y);
1637
+ if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
1638
+ const name = hit.tagName.toLowerCase();
1639
+ const id = hit.id ? `#${hit.id}` : "";
1640
+ const cls = hit.className && typeof hit.className === "string"
1641
+ ? `.${hit.className.trim().split(/\s+/).slice(0, 2).join(".")}`
1642
+ : "";
1643
+ return `${name}${id}${cls}`;
1644
+ });
1645
+ if (covering === "__outside__") {
1646
+ return `its centre is outside the viewport${named} - scroll to it, or pick a target that is on screen`;
1647
+ }
1648
+ if (covering) {
1649
+ return `${covering} is on top of it${named} - a sticky header or an overlay is taking the click`;
1650
+ }
1651
+ return null;
1652
+ } catch {
1653
+ return null;
1654
+ }
1655
+ }
1656
+
1592
1657
  async function extractReadable(page) {
1593
1658
  const paths = [];
1594
1659
  for (const mod of ["@mozilla/readability", "turndown"]) {
1595
1660
  try { paths.push(requireFrom.resolve(mod)); } catch { paths.push(null); }
1596
1661
  }
1597
- if (paths[0] && paths[1]) {
1662
+ const bundles = paths[0] && paths[1]
1663
+ ? [readabilityBundlePath(paths[0]), turndownBundlePath(paths[1])]
1664
+ : [null, null];
1665
+ if (bundles[0] && bundles[1]) {
1598
1666
  try {
1599
- await page.addScriptTag({ path: readabilityBundlePath(paths[0]) });
1600
- await page.addScriptTag({ path: turndownBundlePath(paths[1]) });
1667
+ await page.addScriptTag({ path: bundles[0] });
1668
+ await page.addScriptTag({ path: bundles[1] });
1601
1669
  const out = await page.evaluate(() => {
1602
1670
  const clone = document.cloneNode(true);
1603
1671
  // eslint-disable-next-line no-undef
@@ -1612,35 +1680,127 @@ async function extractReadable(page) {
1612
1680
  // fall through to the plain extraction
1613
1681
  }
1614
1682
  }
1683
+ // The fallback used to clone the body, strip chrome from the clone and read
1684
+ // its innerText. innerText is computed from layout, and a detached node has
1685
+ // none - so on a real page it returned a fraction of the text or nothing,
1686
+ // while a small fixture with everything above the fold looked fine.
1687
+ //
1688
+ // So: find the LIVE container that holds the article, read its innerText, and
1689
+ // drop chrome by line. Reading the live node is what makes the text real; the
1690
+ // line filter is what keeps "Jump to content" out of it.
1615
1691
  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 };
1692
+ const CONTAINERS = [
1693
+ "main article",
1694
+ "article",
1695
+ "main",
1696
+ "[role=main]",
1697
+ "#mw-content-text",
1698
+ "#content",
1699
+ ".markdown-body",
1700
+ ".post-content",
1701
+ ];
1702
+ let host = null;
1703
+ for (const sel of CONTAINERS) {
1704
+ const el = document.querySelector(sel);
1705
+ if (el && (el.innerText || "").trim().length > 200) {
1706
+ host = el;
1707
+ break;
1708
+ }
1709
+ }
1710
+ if (!host) host = document.body;
1711
+
1712
+ const CHROME = [
1713
+ /^jump to (content|navigation|search)$/i,
1714
+ /^skip to (main|content)/i,
1715
+ /^(main )?menu$/i,
1716
+ /^toggle the table of contents$/i,
1717
+ /^(search|sign in|log in|sign up|subscribe|donate)$/i,
1718
+ /^(accept|reject) (all )?cookies?/i,
1719
+ /^cookie (settings|preferences)/i,
1720
+ /^edit (source|this page)$/i,
1721
+ /^\[edit\]$/i,
1722
+ /^(privacy policy|terms of use|contact us)$/i,
1723
+ ];
1724
+ const kept = (host.innerText || "")
1725
+ .split("\n")
1726
+ .filter((line) => {
1727
+ const t = line.trim();
1728
+ if (!t) return true;
1729
+ return !CHROME.some((rx) => rx.test(t));
1730
+ })
1731
+ .join("\n")
1732
+ .replace(/\n{3,}/g, "\n\n")
1733
+ .trim();
1734
+
1735
+ // document.title carries the site suffix ("Bloom filter - Wikipedia").
1736
+ // The first h1 inside the container is the article's own name.
1737
+ const h1 = host.querySelector("h1") || document.querySelector("h1");
1738
+ const title = (h1 && h1.innerText.trim()) || document.title || "";
1739
+ return { markdown: `# ${title}\n\n${kept}`, text: kept };
1622
1740
  });
1623
1741
  }
1624
1742
 
1743
+ // Both packages ship a Node entry point and a browser bundle, and only the
1744
+ // browser bundle can be injected into a page: the Node entry is CommonJS, so
1745
+ // addScriptTag loads it, `module` is undefined, and the whole extraction falls
1746
+ // through to the plain path without ever saying why.
1747
+ //
1748
+ // The file is checked rather than assumed. A layout change upstream returns
1749
+ // null here, which keeps the fallback rather than injecting a file that throws.
1625
1750
  function readabilityBundlePath(resolved) {
1626
- return resolved.replace(/index\.js$/, "Readability.js");
1751
+ const candidate = join(dirname(resolved), "Readability.js");
1752
+ return existsSync(candidate) ? candidate : null;
1627
1753
  }
1628
1754
  function turndownBundlePath(resolved) {
1629
- return resolved.replace(/turndown\.cjs$|index\.js$/, "turndown.js");
1755
+ for (const name of ["turndown.browser.umd.js", "turndown.umd.js", "turndown.js"]) {
1756
+ const candidate = join(dirname(resolved), name);
1757
+ if (existsSync(candidate)) return candidate;
1758
+ }
1759
+ return null;
1760
+ }
1761
+
1762
+ // Every tool below reads the page that is already open. A session that has not
1763
+ // navigated yet is sitting on `about:blank`, where extraction succeeds and
1764
+ // returns nothing: the crawl reports "1 page", the index gains an empty
1765
+ // document, and the search that follows ranks it. Name the missing step
1766
+ // instead of returning an empty success.
1767
+ function blankPageError(page, tool) {
1768
+ const url = page.url();
1769
+ if (url && url !== "about:blank" && !url.startsWith("chrome-error://")) return null;
1770
+ return `ERROR: ${tool} reads the page that is currently open, and nothing has been opened yet (${url || "no url"}). Call web_goto first.`;
1630
1771
  }
1631
1772
 
1632
1773
  async function sameOriginLinks(page) {
1633
1774
  return page.evaluate(() => {
1634
1775
  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];
1776
+ // A documentation page carries two kinds of link: the ones in its body,
1777
+ // which are the material, and the site chrome - a global index, a
1778
+ // "report a bug" form, the breadcrumb back to the root - which is the
1779
+ // same handful of pages on every page of the site. A bounded crawl that
1780
+ // takes them in DOM order spends a third of its budget on chrome before
1781
+ // it reaches the second body link, so body links are handed back first.
1782
+ const CONTENT = ["main article", "article", "main", "[role=main]", "#content", ".markdown-body", ".body"];
1783
+ let host = null;
1784
+ for (const sel of CONTENT) {
1785
+ const el = document.querySelector(sel);
1786
+ if (el && el.querySelector("a[href]")) { host = el; break; }
1787
+ }
1788
+ const collect = (root) => {
1789
+ const found = [];
1790
+ for (const a of root.querySelectorAll("a[href]")) {
1791
+ let u;
1792
+ try { u = new URL(a.getAttribute("href"), location.href); } catch { continue; }
1793
+ if (u.origin !== here) continue;
1794
+ // A link that differs from another only by its query string is the
1795
+ // same document with a feedback form or a tracking tag attached.
1796
+ if (u.search && /^(https?:)?[^?]*\.(html?|md|txt)$/i.test(u.pathname)) u.search = "";
1797
+ u.hash = "";
1798
+ found.push(u.toString());
1799
+ }
1800
+ return found;
1801
+ };
1802
+ const ordered = host ? [...collect(host), ...collect(document)] : collect(document);
1803
+ return [...new Set(ordered)];
1644
1804
  });
1645
1805
  }
1646
1806
 
@@ -1724,7 +1884,7 @@ const WEB_TOOLS = [
1724
1884
  { 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
1885
  { 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
1886
  { 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." } } } },
1887
+ { 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
1888
  { 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
1889
  { 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
1890
  ];
@@ -1768,7 +1928,21 @@ async function handleWeb(name, args) {
1768
1928
  case "web_click": {
1769
1929
  const t = locate(page, args);
1770
1930
  if (t.error) return t.error;
1771
- await t.locator.first().click({ timeout: args.timeout_ms || 5000 });
1931
+ try {
1932
+ await t.locator.first().click({ timeout: args.timeout_ms || 5000 });
1933
+ } catch (e) {
1934
+ // Playwright waits for the element to be actionable and then reports a
1935
+ // timeout, which says nothing about WHY. The commonest reason on a real
1936
+ // page is a target the snapshot listed and the layout hides: the first
1937
+ // link on an accessibility-first site is a "Skip to content" link
1938
+ // positioned off-screen until it takes focus. A caller told only
1939
+ // "Timeout 5000ms exceeded" re-tries the same ref; one told the element
1940
+ // is hidden picks a different one.
1941
+ const why = await describeUnactionable(t.locator.first());
1942
+ return why
1943
+ ? `${ERROR_PREFIX}${t.label} could not be clicked: ${why}`
1944
+ : `${ERROR_PREFIX}${t.label} could not be clicked: ${String(e.message || e).split("\n")[0]}`;
1945
+ }
1772
1946
  return `Clicked: ${t.label}`;
1773
1947
  }
1774
1948
  case "web_type": {
@@ -1799,7 +1973,14 @@ async function handleWeb(name, args) {
1799
1973
  const tree = await target.ariaSnapshot({ mode: "ai" });
1800
1974
  // Register every ref the snapshot handed out, stamped with this
1801
1975
  // snapshot's id so a later navigation invalidates them as a set.
1802
- for (const m of String(tree).matchAll(/\[ref=(e\d+)\]/g)) {
1976
+ //
1977
+ // An element inside an iframe is handed out as `f<frame>e<n>`, not `e<n>`.
1978
+ // Matching only the bare form registers nothing on a page that frames
1979
+ // anything - a cookie banner, an embedded video, an analytics pixel - so
1980
+ // every ref on it is refused as unknown while the snapshot plainly shows
1981
+ // it. Playwright resolves both spellings through the same `aria-ref=`
1982
+ // engine; only this registry needed to accept them.
1983
+ for (const m of String(tree).matchAll(/\[ref=((?:f\d+)?e\d+)\]/g)) {
1803
1984
  _refs.set(m[1], { snapshot: _snapshotId });
1804
1985
  }
1805
1986
  return `${_refs.size} refs\n${tree}`;
@@ -1864,24 +2045,43 @@ async function handleWeb(name, args) {
1864
2045
  }
1865
2046
 
1866
2047
  case "web_extract": {
2048
+ const blank = blankPageError(page, "web_extract");
2049
+ if (blank) return blank;
1867
2050
  const text = await extractReadable(page);
1868
2051
  const body = args.as === "text" ? text.text : text.markdown;
1869
2052
  return fenceUntrusted(page.url(), body);
1870
2053
  }
1871
2054
 
1872
2055
  case "web_map": {
2056
+ const blank = blankPageError(page, "web_map");
2057
+ if (blank) return blank;
1873
2058
  const links = await sameOriginLinks(page);
1874
2059
  const limit = args.limit || 200;
1875
2060
  return links.slice(0, limit).join("\n") || "(no same-origin links on this page)";
1876
2061
  }
1877
2062
 
1878
2063
  case "web_crawl": {
2064
+ const blank = blankPageError(page, "web_crawl");
2065
+ if (blank) return blank;
1879
2066
  const maxPages = Math.min(Number(args.max_pages) || 20, 200);
1880
2067
  const maxDepth = Number(args.max_depth) || 2;
1881
2068
  const delay = Number(args.delay_ms) || 250;
1882
2069
  const respectRobots = args.respect_robots !== false;
1883
2070
  const start = page.url();
1884
2071
  const origin = new URL(start).origin;
2072
+ const wantIndex = args.index !== false;
2073
+ let indexed = 0;
2074
+ let indexDb = null;
2075
+ let crawlDir = null;
2076
+ if (wantIndex) {
2077
+ try {
2078
+ indexDb = ctxIndex.openIndex();
2079
+ crawlDir = join(ctxIndex.INDEX_DIR, "crawl");
2080
+ if (!existsSync(crawlDir)) mkdirSync(crawlDir, { recursive: true });
2081
+ } catch {
2082
+ indexDb = null;
2083
+ }
2084
+ }
1885
2085
  const disallowed = respectRobots ? await robotsDisallow(page, origin) : [];
1886
2086
  const seen = new Set([start]);
1887
2087
  const queue = [{ url: start, depth: 0 }];
@@ -1897,6 +2097,23 @@ async function handleWeb(name, args) {
1897
2097
  }
1898
2098
  const doc = await extractReadable(page);
1899
2099
  out.push(`--- ${url}\n${doc.markdown}`);
2100
+ // A crawl that cannot be searched afterwards is a wall of text: twenty
2101
+ // pages arrive at once and the caller has no way to ask which of them
2102
+ // answers the question. Each page is written beside the index and
2103
+ // indexed under its URL, so context_search ranks the crawl the same way
2104
+ // it ranks an offloaded payload. Failure here never fails the crawl -
2105
+ // the pages are already in the reply.
2106
+ if (wantIndex && indexDb) {
2107
+ try {
2108
+ const slug = url.replace(/[^a-z0-9]+/gi, "-").slice(0, 120);
2109
+ const file = join(crawlDir, `${slug || "page"}.md`);
2110
+ writeFileSync(file, `# ${url}\n\n${doc.markdown}\n`);
2111
+ ctxIndex.indexFile(indexDb, file);
2112
+ indexed += 1;
2113
+ } catch {
2114
+ /* the page is in the reply; the index is the bonus */
2115
+ }
2116
+ }
1900
2117
  if (depth < maxDepth) {
1901
2118
  for (const link of await sameOriginLinks(page)) {
1902
2119
  if (seen.has(link) || seen.size >= maxPages * 4) continue;
@@ -1909,7 +2126,12 @@ async function handleWeb(name, args) {
1909
2126
  if (queue.length && out.length < maxPages) await page.waitForTimeout(delay);
1910
2127
  }
1911
2128
  resetRefs();
1912
- return `${out.length} page(s)\n\n${fenceUntrusted(`${out.length} page(s) from ${origin}`, out.join("\n\n"))}`;
2129
+ const indexNote = indexed
2130
+ ? ` - ${indexed} indexed, searchable with context_search`
2131
+ : wantIndex && indexDb === null
2132
+ ? " - the index could not be opened, so these pages are not searchable"
2133
+ : "";
2134
+ return `${out.length} page(s)${indexNote}\n\n${fenceUntrusted(`${out.length} page(s) from ${origin && origin !== "null" ? origin : start}`, out.join("\n\n"))}`;
1913
2135
  }
1914
2136
 
1915
2137
  case "web_press_key": {
@@ -2278,10 +2500,10 @@ async function handleMedia(name, args) {
2278
2500
  //
2279
2501
  // Unset means everything, which is the behaviour every existing consumer
2280
2502
  // already has. The value is a comma-separated list of families: ios, android,
2281
- // web, design, code, pass, agent, context, research, media. An unknown name is
2503
+ // web, design, code, pass, agent, context, research, media, security. An unknown name is
2282
2504
  // reported on stderr rather than silently ignored, because a typo that quietly
2283
2505
  // disables a family is worse than a noisy one.
2284
- const ALL_CAPS = ["ios", "android", "web", "design", "code", "pass", "agent", "context", "research", "media"];
2506
+ const ALL_CAPS = ["ios", "android", "web", "design", "code", "pass", "agent", "context", "research", "media", "security"];
2285
2507
 
2286
2508
  function enabledCaps() {
2287
2509
  const raw = (process.env.MCP_TOOLKIT_CAPS || "").trim();
@@ -2322,6 +2544,7 @@ const ALL_TOOLS = [
2322
2544
  ...DESIGN_TOOLS,
2323
2545
  ...CODE_TOOLS,
2324
2546
  ...PASS_TOOLS,
2547
+ ...SECURITY_TOOLS,
2325
2548
  ];
2326
2549
 
2327
2550
  // Name -> inputSchema, so the CallTool boundary can enforce the declared shape.
@@ -2584,6 +2807,7 @@ const OUTPUT_SCHEMAS = {
2584
2807
  // made all 78 unavailable in Claude Code - see the note further down.
2585
2808
  ...CODE_OUTPUT_SCHEMAS,
2586
2809
  ...PASS_OUTPUT_SCHEMAS,
2810
+ ...SECURITY_OUTPUT_SCHEMAS,
2587
2811
  ios_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
2588
2812
  android_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
2589
2813
 
@@ -2798,6 +3022,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
2798
3022
  // aborted call cancels the in-flight LSP request instead of orphaning it.
2799
3023
  else if (name.startsWith("code_")) result = await handleCode(name, args || {}, ctx);
2800
3024
  else if (name.startsWith("pass_")) result = await handlePass(name, args || {});
3025
+ else if (name.startsWith("security_")) result = await handleSecurity(name, args || {});
2801
3026
  else return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
2802
3027
  // Same reason as dispatchStep: a handler returns null only from its
2803
3028
  // `default:` arm, so an unrecognised name that happens to carry a known
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.13.0",
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.",
3
+ "version": "3.14.0",
4
+ "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 117 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance, offline CVSS scoring + dependency inventory. Runs standalone over stdio with any MCP client.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "node index.js",
13
- "test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
13
+ "test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs tools/security/__tests__/security.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
14
14
  "gates": "bash scripts/gates.sh"
15
15
  },
16
16
  "keywords": [
@@ -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
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * CVSS 3.1 base-score computation. Pure arithmetic from the specification
3
+ * (https://www.first.org/cvss/v3.1/specification-document), no dependency and
4
+ * no network. Given a base vector string it returns the base score and the
5
+ * qualitative band, which is what the pipeline maps onto its reviewer severity.
6
+ *
7
+ * @module tools/security/cvss
8
+ */
9
+
10
+ const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 };
11
+ const AC = { L: 0.77, H: 0.44 };
12
+ const UI = { N: 0.85, R: 0.62 };
13
+ // Privileges Required depends on Scope: the Changed column is the second entry.
14
+ const PR = { N: [0.85, 0.85], L: [0.62, 0.68], H: [0.27, 0.5] };
15
+ const CIA = { H: 0.56, L: 0.22, N: 0 };
16
+
17
+ const BASE_METRICS = ["AV", "AC", "PR", "UI", "S", "C", "I", "A"];
18
+ const ALLOWED = {
19
+ AV: ["N", "A", "L", "P"],
20
+ AC: ["L", "H"],
21
+ PR: ["N", "L", "H"],
22
+ UI: ["N", "R"],
23
+ S: ["U", "C"],
24
+ C: ["N", "L", "H"],
25
+ I: ["N", "L", "H"],
26
+ A: ["N", "L", "H"],
27
+ };
28
+
29
+ /**
30
+ * CVSS 3.1 Roundup: the smallest 1-decimal number >= input, computed in integer
31
+ * space so float representation cannot round 4.0 up to 4.1. This is the exact
32
+ * algorithm from the specification's appendix.
33
+ * @param {number} input
34
+ * @returns {number}
35
+ */
36
+ export function roundup(input) {
37
+ const intInput = Math.round(input * 100000);
38
+ if (intInput % 10000 === 0) return intInput / 100000;
39
+ return (Math.floor(intInput / 10000) + 1) / 10;
40
+ }
41
+
42
+ /**
43
+ * @param {number} score
44
+ * @returns {"none"|"low"|"medium"|"high"|"critical"}
45
+ */
46
+ export function band(score) {
47
+ if (score === 0) return "none";
48
+ if (score <= 3.9) return "low";
49
+ if (score <= 6.9) return "medium";
50
+ if (score <= 8.9) return "high";
51
+ return "critical";
52
+ }
53
+
54
+ /**
55
+ * Parse a CVSS 3.1 base vector into its metric map, validating the prefix, that
56
+ * every base metric is present exactly once, and that each value is legal.
57
+ * @param {string} vector
58
+ * @returns {{ metrics: Record<string,string> } | { error: string }}
59
+ */
60
+ export function parseVector(vector) {
61
+ if (typeof vector !== "string" || !vector.trim()) return { error: "vector is required" };
62
+ const parts = vector.trim().split("/");
63
+ if (parts[0] !== "CVSS:3.1") return { error: `vector must start with CVSS:3.1, got "${parts[0]}"` };
64
+ const metrics = {};
65
+ for (const part of parts.slice(1)) {
66
+ const [key, value] = part.split(":");
67
+ if (!key || value === undefined) return { error: `malformed metric segment "${part}"` };
68
+ if (metrics[key] !== undefined) return { error: `metric ${key} appears more than once` };
69
+ metrics[key] = value;
70
+ }
71
+ for (const m of BASE_METRICS) {
72
+ if (metrics[m] === undefined) return { error: `missing base metric ${m}` };
73
+ if (!ALLOWED[m].includes(metrics[m])) {
74
+ return { error: `illegal value ${m}:${metrics[m]} (allowed: ${ALLOWED[m].join("/")})` };
75
+ }
76
+ }
77
+ return { metrics };
78
+ }
79
+
80
+ /**
81
+ * Compute the CVSS 3.1 base score and band from a base vector.
82
+ * @param {string} vector
83
+ * @returns {{ vector: string, baseScore: number, band: string } | { error: string }}
84
+ */
85
+ export function scoreVector(vector) {
86
+ const parsed = parseVector(vector);
87
+ if ("error" in parsed) return parsed;
88
+ const m = parsed.metrics;
89
+ const scopeChanged = m.S === "C";
90
+
91
+ const iss = 1 - (1 - CIA[m.C]) * (1 - CIA[m.I]) * (1 - CIA[m.A]);
92
+ const impact = scopeChanged
93
+ ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15)
94
+ : 6.42 * iss;
95
+
96
+ const exploitability = 8.22 * AV[m.AV] * AC[m.AC] * PR[m.PR][scopeChanged ? 1 : 0] * UI[m.UI];
97
+
98
+ let baseScore;
99
+ if (impact <= 0) {
100
+ baseScore = 0;
101
+ } else if (scopeChanged) {
102
+ baseScore = roundup(Math.min(1.08 * (impact + exploitability), 10));
103
+ } else {
104
+ baseScore = roundup(Math.min(impact + exploitability, 10));
105
+ }
106
+
107
+ return { vector: vector.trim(), baseScore, band: band(baseScore) };
108
+ }
Binary file
@@ -0,0 +1,115 @@
1
+ /**
2
+ * security - local, offline helpers for a static security review.
3
+ *
4
+ * OFFLINE BY CONSTRUCTION. Neither tool contacts a registry, an advisory
5
+ * database or anything else: security_cvss_score is pure arithmetic, and
6
+ * security_dep_inventory reads one lockfile off disk and normalizes it. Matching
7
+ * an inventory against known CVEs is a separate, network-bearing step
8
+ * (ai-analyst-toolkit:evidence-registry) kept out of this family on purpose, so
9
+ * the toolkit's stdio-only egress allowlist does not change.
10
+ *
11
+ * GENERIC. No project, brand or repo appears here. Every input is a caller value.
12
+ *
13
+ * @module tools/security
14
+ */
15
+
16
+ import { scoreVector } from "./cvss.js";
17
+ import { inventory, SUPPORTED } from "./deps.js";
18
+
19
+ const ERROR_PREFIX = "ERROR: ";
20
+
21
+ export const SECURITY_TOOLS = [
22
+ {
23
+ name: "security_cvss_score",
24
+ description:
25
+ "Compute the CVSS 3.1 base score and qualitative band from a base vector string, so a finding's score cannot drift from its vector by hand. Pure arithmetic from the FIRST specification, offline. Returns { vector, baseScore, band } where band is none|low|medium|high|critical - the value the pipeline maps to its reviewer severity (critical/high -> blocking, medium -> important, low/none -> suggestion).",
26
+ inputSchema: {
27
+ type: "object",
28
+ additionalProperties: false,
29
+ required: ["vector"],
30
+ properties: {
31
+ vector: {
32
+ type: "string",
33
+ description:
34
+ "A full CVSS 3.1 base vector, e.g. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H. All eight base metrics required; temporal and environmental metrics are ignored.",
35
+ },
36
+ },
37
+ },
38
+ },
39
+ {
40
+ name: "security_dep_inventory",
41
+ description:
42
+ "Parse a dependency lockfile off disk into a normalized inventory of { ecosystem, name, version }. Local parse only - contacts nothing. Supported lockfiles: " +
43
+ Object.keys(SUPPORTED).join(", ") +
44
+ ". Hand the inventory to a CVE lookup (ai-analyst-toolkit:evidence-registry) as a separate step; this tool never reaches the network.",
45
+ inputSchema: {
46
+ type: "object",
47
+ additionalProperties: false,
48
+ required: ["path"],
49
+ properties: {
50
+ path: {
51
+ type: "string",
52
+ description:
53
+ "Absolute path to a supported lockfile (its basename decides the parser, e.g. package-lock.json, Podfile.lock, requirements.txt, go.sum).",
54
+ },
55
+ },
56
+ },
57
+ },
58
+ ];
59
+
60
+ export const SECURITY_OUTPUT_SCHEMAS = {
61
+ security_cvss_score: {
62
+ type: "object",
63
+ required: ["vector", "baseScore", "band"],
64
+ properties: {
65
+ vector: { type: "string" },
66
+ baseScore: { type: "number" },
67
+ band: { type: "string", enum: ["none", "low", "medium", "high", "critical"] },
68
+ },
69
+ },
70
+ security_dep_inventory: {
71
+ type: "object",
72
+ required: ["ecosystem", "file", "count", "dependencies"],
73
+ properties: {
74
+ ecosystem: { type: "string" },
75
+ file: { type: "string" },
76
+ count: { type: "integer" },
77
+ dependencies: {
78
+ type: "array",
79
+ items: {
80
+ type: "object",
81
+ required: ["ecosystem", "name", "version"],
82
+ properties: {
83
+ ecosystem: { type: "string" },
84
+ name: { type: "string" },
85
+ version: { type: "string" },
86
+ },
87
+ },
88
+ },
89
+ },
90
+ },
91
+ };
92
+
93
+ /**
94
+ * @param {string} name
95
+ * @param {Record<string, unknown>} args
96
+ * @returns {Promise<string|null>} JSON string result, or an ERROR: string, or null for an unknown name.
97
+ */
98
+ export async function handleSecurity(name, args = {}) {
99
+ try {
100
+ switch (name) {
101
+ case "security_cvss_score": {
102
+ const r = scoreVector(args.vector);
103
+ return "error" in r ? `${ERROR_PREFIX}${r.error}` : JSON.stringify(r);
104
+ }
105
+ case "security_dep_inventory": {
106
+ const r = inventory(args.path);
107
+ return "error" in r ? `${ERROR_PREFIX}${r.error}` : JSON.stringify(r);
108
+ }
109
+ default:
110
+ return null;
111
+ }
112
+ } catch (e) {
113
+ return `${ERROR_PREFIX}${name}: ${e?.message || String(e)}`;
114
+ }
115
+ }