@mmerterden/multi-agent-toolkit-mcp 3.0.0 → 3.1.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
@@ -17,6 +17,74 @@ Releases before this file exists are recorded in the git tags and commit history
17
17
 
18
18
  ## Unreleased
19
19
 
20
+ ## 3.1.0
21
+
22
+ One new tool and the offload path behind it. Minor rather than patch because the
23
+ tool surface grew: pipeline-side minimums that need `agent_query_output` should
24
+ declare `>= 3.1.0`.
25
+
26
+ ### Added
27
+
28
+ - **`agent_query_output` (tool 84) and large-result offload.** A UI tree, a
29
+ logcat window or an xcresult dump can be tens of thousands of lines. The
30
+ server met that with two lossy caps: `spawnCollect` dropped the OLDEST chunks
31
+ at `SPAWN_OUTPUT_CAP`, and error text was cut at 600 chars. For a build log or
32
+ a test run the interesting part is the END, so "truncated" often meant the
33
+ failure was gone; `ios_get_ui_tree` could write a file, but only when the
34
+ caller thought to pass `path`.
35
+
36
+ Prose payloads over 24 KB are now written whole to
37
+ `~/.claude/logs/multi-agent-toolkit/<tool>-<timestamp>.txt`, and the tool
38
+ returns a head + tail window with the line count and the path.
39
+ `agent_query_output {pattern, path?, context_lines?, max_matches?,
40
+ ignore_case?}` searches that file and returns matching lines with numbered
41
+ context, so a follow-up question does not mean re-running an expensive tool -
42
+ and for a UI dump a second run is not even the same evidence.
43
+
44
+ Only prose is offloaded: a tool that declares an `outputSchema` answers with
45
+ JSON the host parses as `structuredContent`, and a summary would break that
46
+ parse. A write failure falls back to returning the payload whole, because
47
+ spending context is recoverable and dropping the tail is not. Bad regex,
48
+ missing file and nothing-offloaded are reported as `ERROR:` strings, never
49
+ thrown.
50
+
51
+ Pattern source: the multi-agent pipeline's own Phase 4 Step 1.9 (cap the
52
+ diff, write the full copy to `.review-diff.txt`, leave a marker in the
53
+ prompt), which is the same shape yamadashy/repomix uses for packed output.
54
+ Logic lives in `tools/offload/` with 17 unit tests covering the threshold,
55
+ byte-for-byte preservation, tail retention, retrieval of content the inline
56
+ window omitted, the match cap, and every failure mode.
57
+
58
+ ### Fixed (same release, found by reviewing the above)
59
+
60
+ - **The failure path kept none of this.** `run()` / `runAsync()` cut a failed
61
+ command's output to `ERROR_MAX_CHARS` (600) at the source, and the CallTool
62
+ handler returns on `isFailure` before the offload branch - so the case the
63
+ offload was justified by (xcodebuild puts the failing assertion and the
64
+ compiler error at the END) was untouched, and only successful large outputs
65
+ were saved. `truncateError` now offloads the full text and returns a
66
+ character-window head + tail with the path; the old head-only cut remains
67
+ only as the fallback when the write fails.
68
+ - **`agent_query_output` accepted any absolute path.** It resolved
69
+ `args.path` unvalidated, which made a tool whose purpose is "read back what
70
+ this server saved" into an arbitrary-file reader - the opposite direction from
71
+ the 2.26.0 hardening. A caller-supplied path is now confined to the offload
72
+ directory, and confinement is checked BEFORE existence so the refusal cannot
73
+ be used as an existence oracle for paths outside it. The path this server
74
+ recorded itself needs no check: it wrote it.
75
+ - **The offload directory had no retention owner.** It grew without bound at
76
+ 24 KB per entry; the pipeline's `prune-logs` targets a different path and an
77
+ MCP-only user has no pipeline at all. A successful write now prunes the
78
+ directory to the newest 50 files and drops anything older than 7 days, with
79
+ non-`.txt` files and unreadable directories left alone. The file just written
80
+ is never pruned regardless of the retention numbers - review round 1 found
81
+ that a `keepFiles` of 0 deleted it while the returned text still promised its
82
+ path, which is the payload loss this module exists to prevent.
83
+ - **`agent_query_output` carried no `readOnlyHint`.** It was absent from
84
+ `READ_ONLY_TOOLS`, and the test asserting its contract said "read-only" in its
85
+ name while checking only the input schema. Both fixed; the annotation is now
86
+ asserted.
87
+
20
88
  ## 2.26.0
21
89
 
22
90
  Security-hardening release from a multi-agent refactor audit. No tool added or
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  🇹🇷 Türkçe: [README.tr.md](./README.tr.md)
9
9
 
10
- **83 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
+ **84 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
 
@@ -22,7 +22,7 @@ That distinction is worth keeping straight. This line once called five hosts "th
22
22
  - **Store Compliance** (5 tools) - App Store / Play Store readiness; **18-rule deep `ios_app_store_audit`** cross-references Apple ITMS error codes + App Store Review Guidelines (privacy manifest, required-reason API, Info.plist, code signing, entitlements, embedded SDK, IPv6, debug-tool leak, ...)
23
23
  - **Web Automation** (8 tools) - Playwright-powered: goto, click (CSS selectors), type, eval JS, wait for selector, extract text, screenshot. Chromium / WebKit / Firefox engines. **Requires `playwright` peer dependency.**
24
24
  - **Design Audit** (6 tools) - mock-mode vs Figma conformance: scenario inventory, mock detection, mock launch, live UI geometry, pixel/geometry/typography compare, and the HTML/PDF report with its coverage gate
25
- - **Autonomous Agent DSL** (1 tool) - `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.
25
+ - **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.
26
26
 
27
27
  ## Quick Start
28
28
 
package/README.tr.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  🇬🇧 English: [README.md](./README.md)
9
9
 
10
- iOS Simulator, Android Emulator ve headless web kontrolü için **83 araç**. AI kodlama asistanının mobil uygulamalarını görmesini, onlarla etkileşime girmesini ve denetlemesini sağlayan bir MCP sunucusu - ayrıca tarayıcıları sürer, 18-kurallık bir App Store uyumluluk denetimi çalıştırır ve çok-adımlı batch akışlarını orkestre eder.
10
+ iOS Simulator, Android Emulator ve headless web kontrolü için **84 araç**. AI kodlama asistanının mobil uygulamalarını görmesini, onlarla etkileşime girmesini ve denetlemesini sağlayan bir MCP sunucusu - ayrıca tarayıcıları sürer, 18-kurallık bir App Store uyumluluk denetimi çalıştırır ve çok-adımlı batch akışlarını orkestre eder.
11
11
 
12
12
  **Public npm registry** üzerinden dağıtılır - `npx @mmerterden/multi-agent-toolkit-mcp`, auth'suz, token'sız, `~/.npmrc` ayarı gerekmeden çözülür.
13
13
 
@@ -22,7 +22,7 @@ Bu ayrımı net tutmakta fayda var. Bu satır bir zamanlar beş host'u "multi-ag
22
22
  - **Store Compliance** (5 araç) - App Store / Play Store hazırlığı; **18-kurallık derin `ios_app_store_audit`**, Apple ITMS hata kodları + App Store Review Guidelines'a çapraz referans verir (privacy manifest, required-reason API, Info.plist, code signing, entitlements, gömülü SDK, IPv6, debug-tool sızıntısı, ...)
23
23
  - **Web Automation** (8 araç) - Playwright-destekli: goto, click (CSS selector'lar), type, JS eval, selector bekleme, metin çıkarma, screenshot. Chromium / WebKit / Firefox motorları. **`playwright` peer dependency'si gerektirir.**
24
24
  - **Design Audit** (6 araç) - mock-mode vs Figma uygunluğu: scenario envanteri, mock tespiti, mock launch, canlı UI geometrisi, piksel/geometri/tipografi karşılaştırması, ve coverage kapısıyla birlikte HTML/PDF rapor
25
- - **Autonomous Agent DSL** (1 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.
25
+ - **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.
26
26
 
27
27
  ## Hızlı Başlangıç
28
28
 
package/index.js CHANGED
@@ -30,6 +30,11 @@ import {
30
30
  import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
31
31
  import { interactiveElements } from "./tools/ui-inspect/index.js";
32
32
  import { selectCrashReports } from "./tools/crash-logs/index.js";
33
+ import {
34
+ offloadLargeText,
35
+ queryOffloadedOutput,
36
+ offloadedErrorSummary,
37
+ } from "./tools/offload/index.js";
33
38
 
34
39
  const __dirname = dirname(fileURLToPath(import.meta.url));
35
40
  const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
@@ -47,9 +52,16 @@ const ERROR_PREFIX = "ERROR: ";
47
52
  // head of the message.
48
53
  const ERROR_MAX_CHARS = 600;
49
54
 
55
+ // A long failure is the case where the dropped part matters most: xcodebuild
56
+ // puts the failing assertion and the compiler error at the END of its output, so
57
+ // cutting at ERROR_MAX_CHARS deleted the answer and kept the banner. The full
58
+ // text now goes to disk and the caller gets a head + tail window with the path.
59
+ // Only the fallback - when the write fails - is the old head-only truncation.
50
60
  function truncateError(msg) {
51
61
  const flat = String(msg).trim();
52
62
  if (flat.length <= ERROR_MAX_CHARS) return flat;
63
+ const { offloaded, path } = offloadLargeText("error", flat, { minChars: ERROR_MAX_CHARS });
64
+ if (offloaded) return offloadedErrorSummary(flat, path);
53
65
  return `${flat.slice(0, ERROR_MAX_CHARS)}\n... [${flat.length - ERROR_MAX_CHARS} more chars truncated]`;
54
66
  }
55
67
 
@@ -1197,6 +1209,20 @@ const AGENT_TOOLS = [
1197
1209
  required: ["steps"],
1198
1210
  },
1199
1211
  },
1212
+ {
1213
+ name: "agent_query_output",
1214
+ description: "Search the full output of a previous tool call that was too large to return inline. When a tool's payload is offloaded, its text ends with the path of the saved file; this reads that file (the most recent one by default) and returns only the matching lines with context. Use it instead of re-running an expensive tool - re-running a UI dump gives a different tree, so a second call is not the same evidence.",
1215
+ inputSchema: {
1216
+ type: "object",
1217
+ properties: {
1218
+ pattern: { type: "string", description: "JavaScript regular expression, matched per line. Omit to get the file's head." },
1219
+ path: { type: "string", description: "Which offloaded file to read. Defaults to the most recent one this server wrote." },
1220
+ context_lines: { type: "number", description: "Lines of context around each match. Default 2." },
1221
+ max_matches: { type: "number", description: "Stop after this many matches. Default 50." },
1222
+ ignore_case: { type: "boolean", description: "Case-insensitive match. Default false." },
1223
+ },
1224
+ },
1225
+ },
1200
1226
  ];
1201
1227
 
1202
1228
  // design_* is dispatched here too: the tool description offers it, and a design
@@ -1221,6 +1247,7 @@ async function dispatchStep(tool, stepArgs) {
1221
1247
  }
1222
1248
 
1223
1249
  async function handleAgent(name, args) {
1250
+ if (name === "agent_query_output") return queryOffloadedOutput(args, ERROR_PREFIX);
1224
1251
  if (name !== "agent_run_steps") return null;
1225
1252
  const steps = Array.isArray(args.steps) ? args.steps : [];
1226
1253
  const stopOnError = args.stop_on_first_error !== false;
@@ -1341,6 +1368,7 @@ function validateArgs(name, args) {
1341
1368
  // for deciding what may run unattended, so the classification lives here in one
1342
1369
  // auditable place rather than inline on every tool literal.
1343
1370
  const READ_ONLY_TOOLS = new Set([
1371
+ "agent_query_output",
1344
1372
  "ios_list_devices", "ios_screenshot", "ios_list_apps", "ios_get_ui_tree", "ios_get_app_container",
1345
1373
  "ios_accessibility_audit", "ios_archive_audit", "ios_app_store_audit", "ios_xcresult", "ios_visual_diff",
1346
1374
  "ios_list_crashes",
@@ -1700,6 +1728,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1700
1728
  if (isFailure(result)) {
1701
1729
  return { content: [{ type: "text", text: String(result) }], isError: true };
1702
1730
  }
1731
+ // Prose payloads only: a tool with an outputSchema answers with JSON the
1732
+ // caller parses, and a head+tail summary would break that parse. The query
1733
+ // tool is exempt for the obvious reason.
1734
+ if (!OUTPUT_SCHEMAS[name] && name !== "agent_query_output") {
1735
+ const { text, offloaded } = offloadLargeText(name, String(result));
1736
+ if (offloaded) return { content: [{ type: "text", text }] };
1737
+ return withStructured(name, text);
1738
+ }
1703
1739
  return withStructured(name, String(result));
1704
1740
  } catch (e) {
1705
1741
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.0.0",
4
- "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 83 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.1.0",
4
+ "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 84 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",
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/crash-logs/__tests__/crash-logs.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/crash-logs/__tests__/crash-logs.test.mjs tools/offload/__tests__/offload.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
14
14
  "gates": "bash scripts/gates.sh"
15
15
  },
16
16
  "keywords": [
@@ -0,0 +1,201 @@
1
+ // Large-result offload.
2
+ //
3
+ // A UI tree, a logcat window or an xcresult dump can be tens of thousands of
4
+ // lines. Returning it whole spends the caller's whole context; truncating it in
5
+ // place - which is what this server used to do, dropping the oldest chunks at
6
+ // SPAWN_OUTPUT_CAP and cutting error text at 600 chars - throws the tail away,
7
+ // and the tail is where the assertion failure and the crash frame live.
8
+ //
9
+ // So: write the payload to a file, return a head + tail window plus the path,
10
+ // and let the caller pull what it needs with agent_query_output. The pipeline
11
+ // already solved the same problem this way in Phase 4 Step 1.9 (diff cap, full
12
+ // diff to `.review-diff.txt`, marker in the prompt); this is that pattern.
13
+ //
14
+ // Only prose payloads are offloaded by the caller. A tool that declares an
15
+ // outputSchema answers with JSON the host parses as structuredContent, and
16
+ // replacing that with a summary would break the parse.
17
+
18
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from "fs";
19
+ import { join, resolve, sep } from "path";
20
+ import { homedir } from "os";
21
+
22
+ export const OFFLOAD_DIR = join(homedir(), ".claude", "logs", "multi-agent-toolkit");
23
+ export const OFFLOAD_MIN_CHARS = 24 * 1024;
24
+ const HEAD_LINES = 40;
25
+ const TAIL_LINES = 20;
26
+
27
+ // Retention lives with the writer, not with an external cleaner. An MCP-only
28
+ // user has no pipeline installed, so "some other tool prunes it" would mean
29
+ // nobody does, and these files are 24 KB and up. Newest-N plus an age cut, both
30
+ // applied after a successful write, cost one readdir.
31
+ export const OFFLOAD_KEEP_FILES = 50;
32
+ export const OFFLOAD_KEEP_DAYS = 7;
33
+
34
+ // `keep` is the file the caller just wrote. It is never deleted, whatever the
35
+ // retention numbers say: the returned text promises that path to the caller, and
36
+ // this module exists precisely because losing the payload is the failure mode. A
37
+ // keepFiles of 0 must bound the directory, not break the answer.
38
+ export function pruneOffloadDir(dir, opts = {}) {
39
+ const keepFiles = opts.keepFiles ?? OFFLOAD_KEEP_FILES;
40
+ const keepDays = opts.keepDays ?? OFFLOAD_KEEP_DAYS;
41
+ const now = opts.now ?? Date.now();
42
+ const keep = typeof opts.keep === "string" ? resolve(opts.keep) : null;
43
+ const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
44
+ let removed = 0;
45
+ try {
46
+ const entries = readdirSync(dir)
47
+ .filter((n) => n.endsWith(".txt"))
48
+ .map((n) => {
49
+ const full = join(dir, n);
50
+ try {
51
+ return { full, mtime: statSync(full).mtimeMs };
52
+ } catch {
53
+ return null;
54
+ }
55
+ })
56
+ .filter(Boolean)
57
+ .sort((a, b) => b.mtime - a.mtime);
58
+
59
+ for (let i = 0; i < entries.length; i++) {
60
+ if (keep && resolve(entries[i].full) === keep) continue;
61
+ const tooOld = entries[i].mtime < cutoff;
62
+ const tooMany = i >= keepFiles;
63
+ if (!tooOld && !tooMany) continue;
64
+ try {
65
+ unlinkSync(entries[i].full);
66
+ removed++;
67
+ } catch {
68
+ // A file another process holds open is skipped, not fatal.
69
+ }
70
+ }
71
+ } catch {
72
+ // No directory yet, or an unreadable one: nothing to prune.
73
+ }
74
+ return removed;
75
+ }
76
+
77
+ let lastOffload = null;
78
+
79
+ export function lastOffloadRecord() {
80
+ return lastOffload;
81
+ }
82
+
83
+ export function resetOffloadState() {
84
+ lastOffload = null;
85
+ }
86
+
87
+ // Errors arrive as one or two long lines, so the line-based window degenerates:
88
+ // head and tail would both be the whole message. This builds the equivalent
89
+ // window in characters and is used by the failure path.
90
+ export function offloadedErrorSummary(text, path, headChars = 400, tailChars = 250) {
91
+ if (text.length <= headChars + tailChars) return text;
92
+ const omitted = text.length - headChars - tailChars;
93
+ return (
94
+ `${text.slice(0, headChars)}\n... [${omitted} chars omitted. Full output at ${path} - ` +
95
+ `read it with agent_query_output {pattern: "..."} ] ...\n${text.slice(-tailChars)}`
96
+ );
97
+ }
98
+
99
+ export function offloadLargeText(tool, text, opts = {}) {
100
+ const dir = opts.dir ?? OFFLOAD_DIR;
101
+ const minChars = opts.minChars ?? OFFLOAD_MIN_CHARS;
102
+ const stamp = opts.stamp ?? new Date().toISOString().replace(/[:.]/g, "-");
103
+ if (text.length < minChars) return { text, offloaded: false };
104
+
105
+ let path;
106
+ try {
107
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
108
+ path = join(dir, `${tool}-${stamp}.txt`);
109
+ writeFileSync(path, text);
110
+ } catch {
111
+ // A write failure must not lose the payload: fall back to returning it
112
+ // whole. Spending context is recoverable; dropping the tail is not.
113
+ return { text, offloaded: false };
114
+ }
115
+
116
+ pruneOffloadDir(dir, { ...opts, keep: path });
117
+
118
+ const lines = text.split("\n");
119
+ lastOffload = { tool, path, lines: lines.length, bytes: text.length };
120
+ const head = lines.slice(0, HEAD_LINES).join("\n");
121
+ const tail = lines.slice(-TAIL_LINES).join("\n");
122
+ const hidden = Math.max(0, lines.length - HEAD_LINES - TAIL_LINES);
123
+ return {
124
+ offloaded: true,
125
+ path,
126
+ text:
127
+ `${head}\n\n[... ${hidden} line(s) not shown. Full output (${lines.length} lines, ` +
128
+ `${text.length} bytes) saved to ${path} - search it with ` +
129
+ `agent_query_output {pattern: "..."} instead of re-running this tool ...]\n\n${tail}`,
130
+ };
131
+ }
132
+
133
+ // A caller-supplied path is confined to the offload directory. The tool's whole
134
+ // purpose is "read back the file this server just wrote", and accepting any
135
+ // absolute path made it an arbitrary-file reader - which contradicts the 2.26.0
136
+ // hardening pass rather than extending it. The path this server itself recorded
137
+ // needs no check: it wrote it.
138
+ function pathAllowed(candidate, allowedDirs) {
139
+ const target = resolve(candidate);
140
+ return allowedDirs.some((dir) => {
141
+ const root = resolve(dir);
142
+ return target === root || target.startsWith(root + sep);
143
+ });
144
+ }
145
+
146
+ // Reads back an offloaded payload. Returns matching lines with context rather
147
+ // than the file, so a 40k-line UI tree answers a question without becoming the
148
+ // answer. A bad regex is reported, never thrown: the caller can fix a pattern
149
+ // but cannot fix a crashed server.
150
+ export function queryOffloadedOutput(args = {}, errorPrefix = "ERROR: ", opts = {}) {
151
+ const allowedDirs = opts.allowedDirs ?? [OFFLOAD_DIR];
152
+ const asked = typeof args.path === "string" && args.path ? args.path : null;
153
+ if (asked && !pathAllowed(asked, allowedDirs)) {
154
+ return `${errorPrefix}path outside the offload directory is refused: ${asked}. This tool reads only what this server saved under ${allowedDirs.join(", ")}.`;
155
+ }
156
+ const path = asked ?? lastOffload?.path;
157
+ if (!path) {
158
+ return `${errorPrefix}no offloaded output to search: nothing has been saved in this session, and no path was given`;
159
+ }
160
+ if (!existsSync(path)) {
161
+ return `${errorPrefix}offloaded output not found at ${path}`;
162
+ }
163
+ let body;
164
+ try {
165
+ body = readFileSync(path, "utf8");
166
+ } catch (e) {
167
+ return `${errorPrefix}cannot read ${path}: ${e.message}`;
168
+ }
169
+
170
+ const lines = body.split("\n");
171
+ const contextLines = Number.isFinite(args.context_lines)
172
+ ? Math.max(0, Math.trunc(args.context_lines))
173
+ : 2;
174
+ const maxMatches = Number.isFinite(args.max_matches) ? Math.max(1, Math.trunc(args.max_matches)) : 50;
175
+
176
+ if (typeof args.pattern !== "string" || args.pattern === "") {
177
+ return `${path} (${lines.length} lines)\n\n${lines.slice(0, 60).join("\n")}`;
178
+ }
179
+
180
+ let re;
181
+ try {
182
+ re = new RegExp(args.pattern, args.ignore_case ? "i" : "");
183
+ } catch (e) {
184
+ return `${errorPrefix}invalid pattern: ${e.message}`;
185
+ }
186
+
187
+ const blocks = [];
188
+ let matches = 0;
189
+ for (let i = 0; i < lines.length && matches < maxMatches; i++) {
190
+ if (!re.test(lines[i])) continue;
191
+ matches++;
192
+ const from = Math.max(0, i - contextLines);
193
+ const to = Math.min(lines.length - 1, i + contextLines);
194
+ const chunk = [];
195
+ for (let j = from; j <= to; j++) chunk.push(`${j + 1}${j === i ? ":" : "-"} ${lines[j]}`);
196
+ blocks.push(chunk.join("\n"));
197
+ }
198
+ if (!matches) return `${path}: no line matches /${args.pattern}/ (${lines.length} lines searched)`;
199
+ const capped = matches >= maxMatches ? ` (stopped at max_matches=${maxMatches})` : "";
200
+ return `${path}: ${matches} match(es)${capped}\n\n${blocks.join("\n--\n")}`;
201
+ }