@mario.andreschak/mcp-filesystem 3.45.0 → 3.45.2
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/dist/index.js +25 -2
- package/dist/index.js.map +1 -1
- package/dist/resources.js +1 -1
- package/dist/shared.d.ts +0 -7
- package/dist/shared.js +0 -40
- package/dist/shared.js.map +1 -1
- package/dist/tools.d.ts +19 -1
- package/dist/tools.js +480 -141
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -14,20 +14,18 @@
|
|
|
14
14
|
* as a HARD CEILING an operator sets — no path may ever escape it, and
|
|
15
15
|
* - user-configured roots persisted via the MCP manager UI (issue #170), which
|
|
16
16
|
* may only narrow WITHIN the env ceiling (never widen it).
|
|
17
|
-
* When neither is set the server has full host access.
|
|
18
|
-
* deny layer (issue #260, see protectedPaths.ts) can additionally block a
|
|
19
|
-
* hardcoded set of sensitive home locations regardless of configured roots. It
|
|
20
|
-
* is disabled by default.
|
|
17
|
+
* When neither is set the server has full host access.
|
|
21
18
|
*
|
|
22
19
|
* Every tool returns a machine-readable JSON envelope both as MCP
|
|
23
20
|
* `structuredContent` and as a single text content block (for backward-compat
|
|
24
21
|
* clients); errors are returned as `isError: true` results rather than thrown.
|
|
25
22
|
*/
|
|
26
23
|
import path from 'node:path';
|
|
27
|
-
import { promises as fs
|
|
24
|
+
import { promises as fs } from 'node:fs';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
28
26
|
import { createHash } from 'node:crypto';
|
|
29
27
|
import readline from 'node:readline';
|
|
30
|
-
import {
|
|
28
|
+
import { createLogger, getDataDir, isInside, loadEffectiveRoots, } from './shared.js';
|
|
31
29
|
import { recordTouchedFile } from './resources.js';
|
|
32
30
|
import { detectMediaFile, mimeTypeFromExtension, mediaTypeFromMime, looksBinaryHeuristic } from './media.js';
|
|
33
31
|
const FILESYSTEM_SERVER_NAME = 'filesystem';
|
|
@@ -79,10 +77,90 @@ const MAX_MEDIA_BYTES = 32 * 1024 * 1024;
|
|
|
79
77
|
const SEARCH_MAX_FILE_BYTES = 5_000_000;
|
|
80
78
|
/** #287: how many files to scan concurrently during a content search. */
|
|
81
79
|
const SEARCH_CONCURRENCY = 16;
|
|
80
|
+
/** Directory reads are independent I/O; a bounded breadth-first batch avoids
|
|
81
|
+
* serially walking every directory on large workspaces. */
|
|
82
|
+
const SEARCH_DIRECTORY_CONCURRENCY = 32;
|
|
83
|
+
/** Do not turn a fast filesystem walk into a notification flood. */
|
|
84
|
+
const SEARCH_PROGRESS_INTERVAL_MS = 500;
|
|
85
|
+
/** Silent searches still report liveness so the host can distinguish work from a hang. */
|
|
86
|
+
const SEARCH_PROGRESS_HEARTBEAT_MS = 5_000;
|
|
82
87
|
/** #287: context lines shown around each `read_file` pattern match. */
|
|
83
88
|
const READ_PATTERN_CONTEXT = 2;
|
|
84
89
|
/** #287: cap on matches returned by a single `read_file` pattern grep. */
|
|
85
90
|
const MAX_READ_PATTERN_MATCHES = 200;
|
|
91
|
+
/** #487: bound caller-controlled regular expressions before compiling them. */
|
|
92
|
+
const MAX_READ_PATTERN_LENGTH = 4_096;
|
|
93
|
+
/**
|
|
94
|
+
* Coalesced MCP progress for search. The numeric field is a notification
|
|
95
|
+
* sequence (MCP requires it to increase); useful counters live in the message.
|
|
96
|
+
*/
|
|
97
|
+
function createSearchProgressReporter(context) {
|
|
98
|
+
const report = context?.onProgress;
|
|
99
|
+
if (!report)
|
|
100
|
+
return { update: () => undefined, stop: async () => undefined };
|
|
101
|
+
let snapshot = {
|
|
102
|
+
phase: 'starting',
|
|
103
|
+
directories: 0,
|
|
104
|
+
files: 0,
|
|
105
|
+
matches: 0,
|
|
106
|
+
};
|
|
107
|
+
let sequence = 0;
|
|
108
|
+
let lastDeliveredAt = 0;
|
|
109
|
+
let stopped = false;
|
|
110
|
+
let chain = Promise.resolve();
|
|
111
|
+
const deliver = () => {
|
|
112
|
+
if (stopped || context?.signal?.aborted)
|
|
113
|
+
return;
|
|
114
|
+
lastDeliveredAt = Date.now();
|
|
115
|
+
const current = ++sequence;
|
|
116
|
+
const message = `${snapshot.phase}: ${snapshot.directories} directories, ${snapshot.files} files, ${snapshot.matches} matches`;
|
|
117
|
+
chain = chain
|
|
118
|
+
.then(() => report({ progress: current, message }))
|
|
119
|
+
.catch((error) => log.debug('Could not deliver filesystem search progress', error));
|
|
120
|
+
};
|
|
121
|
+
const heartbeat = setInterval(deliver, SEARCH_PROGRESS_HEARTBEAT_MS);
|
|
122
|
+
heartbeat.unref?.();
|
|
123
|
+
return {
|
|
124
|
+
update: (patch, force = false) => {
|
|
125
|
+
snapshot = { ...snapshot, ...patch };
|
|
126
|
+
if (force || Date.now() - lastDeliveredAt >= SEARCH_PROGRESS_INTERVAL_MS)
|
|
127
|
+
deliver();
|
|
128
|
+
},
|
|
129
|
+
stop: async () => {
|
|
130
|
+
if (stopped)
|
|
131
|
+
return;
|
|
132
|
+
clearInterval(heartbeat);
|
|
133
|
+
if (!context?.signal?.aborted && Date.now() - lastDeliveredAt > 0)
|
|
134
|
+
deliver();
|
|
135
|
+
stopped = true;
|
|
136
|
+
await chain;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* ripgrep is an installer-provided acceleration, not a runtime requirement.
|
|
142
|
+
* Cache by PATH value so a process whose environment is refreshed after an
|
|
143
|
+
* install can discover it without restarting. `undefined` means automatic,
|
|
144
|
+
* while `null` is a test-only forced fallback.
|
|
145
|
+
*/
|
|
146
|
+
let ripgrepCache;
|
|
147
|
+
let ripgrepExecutableForTests;
|
|
148
|
+
const activeRipgrepChildren = new Set();
|
|
149
|
+
/** Test seam: force a ripgrep executable, force the Node fallback with null, or reset with undefined. */
|
|
150
|
+
export function _setRipgrepExecutableForTests(value) {
|
|
151
|
+
ripgrepExecutableForTests = value;
|
|
152
|
+
ripgrepCache = undefined;
|
|
153
|
+
}
|
|
154
|
+
/** Kill external search accelerators before the filesystem MCP process exits. */
|
|
155
|
+
export function shutdownFilesystemSearches() {
|
|
156
|
+
for (const child of activeRipgrepChildren) {
|
|
157
|
+
try {
|
|
158
|
+
child.kill();
|
|
159
|
+
}
|
|
160
|
+
catch { /* best-effort shutdown */ }
|
|
161
|
+
}
|
|
162
|
+
activeRipgrepChildren.clear();
|
|
163
|
+
}
|
|
86
164
|
/** Return a structured payload as BOTH MCP structuredContent and a text fallback. */
|
|
87
165
|
function dualResult(payload) {
|
|
88
166
|
return {
|
|
@@ -104,6 +182,19 @@ function errorResult(message) {
|
|
|
104
182
|
isError: true,
|
|
105
183
|
};
|
|
106
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Translate the slash-drive form some models emit into a native Windows path.
|
|
187
|
+
* Keep the platform injectable so the narrow conversion can be verified on any
|
|
188
|
+
* host; all canonicalization and confinement still happen in resolvePath().
|
|
189
|
+
*/
|
|
190
|
+
export function _normalizeSlashDrivePathForTests(raw, platform = process.platform) {
|
|
191
|
+
const match = /^\/([A-Za-z])(?:\/|$)/.exec(raw);
|
|
192
|
+
if (platform !== 'win32' || !match)
|
|
193
|
+
return raw;
|
|
194
|
+
const driveRoot = `${match[1].toUpperCase()}:\\`;
|
|
195
|
+
const remainder = raw.slice(match[0].length).split('/').join(path.win32.sep);
|
|
196
|
+
return remainder ? `${driveRoot}${remainder}` : driveRoot;
|
|
197
|
+
}
|
|
107
198
|
/**
|
|
108
199
|
* Resolve a user-supplied path against the data dir (for relative paths) and
|
|
109
200
|
* enforce the effective confinement roots. An empty roots array blocks all access.
|
|
@@ -113,32 +204,23 @@ async function resolvePath(input, roots) {
|
|
|
113
204
|
const raw = typeof input === 'string' ? input.trim() : '';
|
|
114
205
|
if (!raw)
|
|
115
206
|
throw new Error('Provide "path".');
|
|
207
|
+
const normalized = _normalizeSlashDrivePathForTests(raw);
|
|
116
208
|
const dataDir = getDataDir();
|
|
117
|
-
const resolved = path.isAbsolute(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (await isProtectedPathsEnabled()) {
|
|
121
|
-
const prot = isProtected(resolved);
|
|
122
|
-
if (prot.denied) {
|
|
123
|
-
throw new Error(`Path "${resolved}" is within a protected location (${prot.matchedRoot}) and is blocked by the FLUJO built-in server protected-path policy. ` +
|
|
124
|
-
`Disable "Protect sensitive home-directory paths" in Experimental Features or set ${ALLOW_PROTECTED_PATHS_ENV}=1 to override.`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
209
|
+
const resolved = path.isAbsolute(normalized)
|
|
210
|
+
? path.resolve(normalized)
|
|
211
|
+
: path.resolve(dataDir, normalized);
|
|
127
212
|
if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
|
|
128
213
|
throw new Error(`Path "${resolved}" is outside the configured filesystem roots.`);
|
|
129
214
|
}
|
|
130
215
|
return resolved;
|
|
131
216
|
}
|
|
132
217
|
export function filesystemToolDefinitions() {
|
|
133
|
-
const pathProp = { type: 'string', description: 'File or directory path. Relative paths
|
|
218
|
+
const pathProp = { type: 'string', description: 'File or directory path. Relative paths start at the FLUJO data directory.' };
|
|
134
219
|
return [
|
|
135
220
|
{
|
|
136
221
|
name: 'read_file',
|
|
137
222
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
138
|
-
description:
|
|
139
|
-
`Media files (images, audio, video) are detected automatically and returned as MCP media content items; read them with "path" alone ("pattern"/"from"/"to" do not apply to binary media, and the large-file rule below is waived for them, up to ${MAX_MEDIA_BYTES / (1024 * 1024)} MB). FLUJO persists the bytes as a run resource and re-attaches them to the conversation as a real media input, so a model that accepts that modality perceives the file directly; a model that does not receives the run-resource URI and can still pass it to other tools. ` +
|
|
140
|
-
`For large NON-MEDIA files (> ${LARGE_FILE_BYTES / 1000} KB) read WHOLE (no "from"/"to"), a "pattern" is REQUIRED: the server greps the file and returns only matching lines (with a little surrounding context) so you can follow up with targeted "from"/"to" reads. Pass pattern "*" to force-read the entire large file anyway. ` +
|
|
141
|
-
'Single text reads return { path, from, to, totalLines, content, truncated, contentHash, matches? } unchanged; media reads return { path, mediaType, mimeType, size, encoding } alongside the media item. Batch reads return { files }, with one ordered success or { requestedPath, path?, error } record per input; individual failures do not discard successful reads. Batch output is limited to 1,000,000 serialized characters. Pass contentHash back as "expectedHash" on a follow-up edit_file/write_file to guard against the file changing in between (TOCTOU).',
|
|
223
|
+
description: `Read text or media files. Use "path" for one file or "paths" for up to 25; do not use both. For text, "from"/"to" select lines and "pattern" is a case-insensitive regular expression. Regex metacharacters have their usual meaning and must be escaped for literal matching; invalid expressions or expressions over ${MAX_READ_PATTERN_LENGTH} characters return an error. Files over ${LARGE_FILE_BYTES / 1000} KB need a line range or pattern; the exact pattern "*" is a reserved sentinel that reads the whole file. Media limit: ${MAX_MEDIA_BYTES / (1024 * 1024)} MB. Use the returned contentHash as expectedHash when editing.`,
|
|
142
224
|
inputSchema: {
|
|
143
225
|
type: 'object',
|
|
144
226
|
properties: {
|
|
@@ -152,7 +234,7 @@ export function filesystemToolDefinitions() {
|
|
|
152
234
|
},
|
|
153
235
|
from: { type: 'number', description: 'Optional 1-based first line to return (inclusive).' },
|
|
154
236
|
to: { type: 'number', description: 'Optional 1-based last line to return (inclusive).' },
|
|
155
|
-
pattern: { type: 'string', description:
|
|
237
|
+
pattern: { type: 'string', description: `Case-insensitive regular expression, limited to ${MAX_READ_PATTERN_LENGTH} characters. Regex metacharacters have their usual meaning; escape them for a literal match. Invalid or over-limit expressions return a tool error. Required to read a large file whole without a "from"/"to" range; matching lines plus context are returned instead of the full body. The exact value "*" is reserved to read the whole file.` },
|
|
156
238
|
},
|
|
157
239
|
},
|
|
158
240
|
outputSchema: {
|
|
@@ -184,7 +266,7 @@ export function filesystemToolDefinitions() {
|
|
|
184
266
|
{
|
|
185
267
|
name: 'write_file',
|
|
186
268
|
annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
|
|
187
|
-
description: '
|
|
269
|
+
description: 'Write a text file and create missing parent directories. Modes: overwrite (default), append, or insert before startLine. For a range overwrite, give startLine and endLine. Use expectedHash from read_file to reject changes to a stale file; it is ignored for whole-file overwrite.',
|
|
188
270
|
// #216: feed the docked diff canvas (ui://devcanvas/diff) so successive
|
|
189
271
|
// writes update one persistent tab. See internal/filesystemResources.ts.
|
|
190
272
|
_meta: { ui: { resourceUri: 'ui://devcanvas/diff' } },
|
|
@@ -217,7 +299,7 @@ export function filesystemToolDefinitions() {
|
|
|
217
299
|
{
|
|
218
300
|
name: 'edit_file',
|
|
219
301
|
annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
|
|
220
|
-
description: 'Edit a text file
|
|
302
|
+
description: 'Edit a text file with either "edits" or "diff"; do not use both. Each oldText must match once, so include enough context to make it unique. A unified diff is applied atomically. Use expectedHash from read_file to reject edits to a stale file.',
|
|
221
303
|
// #216: also feed the docked diff canvas so edits show live in the canvas.
|
|
222
304
|
_meta: { ui: { resourceUri: 'ui://devcanvas/diff' } },
|
|
223
305
|
inputSchema: {
|
|
@@ -264,7 +346,7 @@ export function filesystemToolDefinitions() {
|
|
|
264
346
|
{
|
|
265
347
|
name: 'list_dir',
|
|
266
348
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
267
|
-
description: 'List
|
|
349
|
+
description: 'List a directory. Each entry includes name, type, and size.',
|
|
268
350
|
inputSchema: {
|
|
269
351
|
type: 'object',
|
|
270
352
|
properties: { path: pathProp },
|
|
@@ -289,7 +371,7 @@ export function filesystemToolDefinitions() {
|
|
|
289
371
|
{
|
|
290
372
|
name: 'file_browser_ui',
|
|
291
373
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
292
|
-
description: '
|
|
374
|
+
description: 'Ask the user to pick a file in an interactive browser. This returns before they choose. After calling it, STOP and wait for a follow-up user message containing the selected path.',
|
|
293
375
|
// MCP Apps (#97): this tool exists solely to surface the file-browser app
|
|
294
376
|
// (ui://filesystem/browser). See internal/filesystemResources.ts.
|
|
295
377
|
_meta: { ui: { resourceUri: 'ui://filesystem/browser' } },
|
|
@@ -304,7 +386,7 @@ export function filesystemToolDefinitions() {
|
|
|
304
386
|
{
|
|
305
387
|
name: 'dir_tree',
|
|
306
388
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
307
|
-
description: '
|
|
389
|
+
description: 'List a directory tree recursively, limited by depth.',
|
|
308
390
|
inputSchema: {
|
|
309
391
|
type: 'object',
|
|
310
392
|
properties: {
|
|
@@ -327,7 +409,7 @@ export function filesystemToolDefinitions() {
|
|
|
327
409
|
{
|
|
328
410
|
name: 'search',
|
|
329
411
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
330
|
-
description: 'Search a directory tree
|
|
412
|
+
description: 'Search a directory tree by namePattern and/or text content. Matching is case-insensitive. Content matches include line numbers. Binary and very large files are skipped.',
|
|
331
413
|
inputSchema: {
|
|
332
414
|
type: 'object',
|
|
333
415
|
properties: {
|
|
@@ -356,7 +438,7 @@ export function filesystemToolDefinitions() {
|
|
|
356
438
|
{
|
|
357
439
|
name: 'get_file_info',
|
|
358
440
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
359
|
-
description: '
|
|
441
|
+
description: 'Get a path\'s type, size, and creation and modification times.',
|
|
360
442
|
inputSchema: {
|
|
361
443
|
type: 'object',
|
|
362
444
|
properties: { path: pathProp },
|
|
@@ -379,7 +461,7 @@ export function filesystemToolDefinitions() {
|
|
|
379
461
|
{
|
|
380
462
|
name: 'create_directory',
|
|
381
463
|
annotations: WRITE_ANNOTATIONS,
|
|
382
|
-
description: 'Create a directory
|
|
464
|
+
description: 'Create a directory and any missing parents. Succeeds if it exists.',
|
|
383
465
|
inputSchema: {
|
|
384
466
|
type: 'object',
|
|
385
467
|
properties: { path: pathProp },
|
|
@@ -394,7 +476,7 @@ export function filesystemToolDefinitions() {
|
|
|
394
476
|
{
|
|
395
477
|
name: 'move',
|
|
396
478
|
annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
|
|
397
|
-
description: 'Move or rename a file
|
|
479
|
+
description: 'Move or rename a file or directory.',
|
|
398
480
|
inputSchema: {
|
|
399
481
|
type: 'object',
|
|
400
482
|
properties: {
|
|
@@ -412,7 +494,7 @@ export function filesystemToolDefinitions() {
|
|
|
412
494
|
{
|
|
413
495
|
name: 'delete',
|
|
414
496
|
annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
|
|
415
|
-
description: 'Delete a file or directory.
|
|
497
|
+
description: 'Delete a file or directory. Set recursive to true for a non-empty directory.',
|
|
416
498
|
inputSchema: {
|
|
417
499
|
type: 'object',
|
|
418
500
|
properties: {
|
|
@@ -430,12 +512,7 @@ export function filesystemToolDefinitions() {
|
|
|
430
512
|
{
|
|
431
513
|
name: 'get_allowed_directories',
|
|
432
514
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
433
|
-
description: '
|
|
434
|
-
'currently allowed to access. The list is the merged result of ' +
|
|
435
|
-
'server-level roots (configured via FLUJO MCP settings), MCP-node-level ' +
|
|
436
|
-
'roots (set in the FlowBuilder), and the FLUJO_FS_ROOTS environment ' +
|
|
437
|
-
'variable ceiling. When no roots are configured at all, returns the FLUJO ' +
|
|
438
|
-
'data directory as the default (so the file browser is usable out of the box).',
|
|
515
|
+
description: 'List the directories this server is allowed to access.',
|
|
439
516
|
inputSchema: {
|
|
440
517
|
type: 'object',
|
|
441
518
|
properties: {},
|
|
@@ -526,40 +603,28 @@ function mediaResult(filePath, buf, mediaType, mimeType) {
|
|
|
526
603
|
};
|
|
527
604
|
return result;
|
|
528
605
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const lower = needle.toLowerCase();
|
|
536
|
-
const matches = [];
|
|
537
|
-
let truncated = false;
|
|
538
|
-
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
|
539
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
540
|
-
let lineNo = 0;
|
|
606
|
+
function compileReadPattern(pattern) {
|
|
607
|
+
if (!pattern || pattern === '*')
|
|
608
|
+
return undefined;
|
|
609
|
+
if (pattern.length > MAX_READ_PATTERN_LENGTH) {
|
|
610
|
+
throw new Error(`Invalid regular expression: pattern exceeds the ${MAX_READ_PATTERN_LENGTH}-character limit.`);
|
|
611
|
+
}
|
|
541
612
|
try {
|
|
542
|
-
|
|
543
|
-
lineNo++;
|
|
544
|
-
if (line.toLowerCase().includes(lower)) {
|
|
545
|
-
matches.push({ line: lineNo, text: line.slice(0, 400) });
|
|
546
|
-
if (matches.length >= max) {
|
|
547
|
-
truncated = true;
|
|
548
|
-
break;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
}
|
|
613
|
+
return new RegExp(pattern, 'i');
|
|
552
614
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
615
|
+
catch (err) {
|
|
616
|
+
const detail = err instanceof Error
|
|
617
|
+
? err.message.replace(/^Invalid regular expression:\s*/i, '')
|
|
618
|
+
: 'the pattern could not be compiled.';
|
|
619
|
+
throw new Error(`Invalid regular expression: ${detail}`);
|
|
556
620
|
}
|
|
557
|
-
return { matches, truncated };
|
|
558
621
|
}
|
|
559
622
|
async function readSingleFileTool(args, roots) {
|
|
560
623
|
const filePath = await resolvePath(args.path, roots);
|
|
561
624
|
const hasRange = typeof args.from === 'number' || typeof args.to === 'number';
|
|
562
|
-
const
|
|
625
|
+
const suppliedPattern = typeof args.pattern === 'string' ? args.pattern : '';
|
|
626
|
+
const pattern = suppliedPattern.trim() ? suppliedPattern : '';
|
|
627
|
+
const patternRegex = compileReadPattern(pattern);
|
|
563
628
|
// #287: guard whole-file reads of large files. Stat first so we never buffer a
|
|
564
629
|
// huge file just to reject it. An explicit line range or a `pattern` opts out.
|
|
565
630
|
let stat;
|
|
@@ -629,10 +694,12 @@ async function readSingleFileTool(args, roots) {
|
|
|
629
694
|
const content = buf.toString('utf8');
|
|
630
695
|
const lines = splitLines(content);
|
|
631
696
|
const totalLines = lines.length;
|
|
632
|
-
const lower = pattern.toLowerCase();
|
|
633
697
|
const matches = [];
|
|
634
698
|
for (let i = 0; i < lines.length; i++) {
|
|
635
|
-
|
|
699
|
+
// The current expression has no stateful flags, but resetting lastIndex
|
|
700
|
+
// keeps this safe if flags change later.
|
|
701
|
+
patternRegex.lastIndex = 0;
|
|
702
|
+
if (patternRegex.test(lines[i])) {
|
|
636
703
|
matches.push({ line: i + 1, text: lines[i].slice(0, 400) });
|
|
637
704
|
if (matches.length >= MAX_READ_PATTERN_MATCHES)
|
|
638
705
|
break;
|
|
@@ -1157,103 +1224,368 @@ async function dirTreeTool(args, roots) {
|
|
|
1157
1224
|
const tree = await walk(rootPath, 1);
|
|
1158
1225
|
return dualResult({ path: rootPath, depth, truncated, tree });
|
|
1159
1226
|
}
|
|
1227
|
+
function pathEnvironmentValue() {
|
|
1228
|
+
const entry = Object.entries(process.env).find(([name]) => name.toLowerCase() === 'path');
|
|
1229
|
+
return entry?.[1] ?? '';
|
|
1230
|
+
}
|
|
1231
|
+
/** Locate the installer-provided ripgrep without invoking a shell. */
|
|
1232
|
+
async function resolveRipgrepExecutable() {
|
|
1233
|
+
if (ripgrepExecutableForTests !== undefined)
|
|
1234
|
+
return ripgrepExecutableForTests;
|
|
1235
|
+
const pathValue = pathEnvironmentValue();
|
|
1236
|
+
if (ripgrepCache?.pathValue === pathValue)
|
|
1237
|
+
return ripgrepCache.executable;
|
|
1238
|
+
const names = process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg'];
|
|
1239
|
+
let executable = null;
|
|
1240
|
+
for (const rawDir of pathValue.split(path.delimiter)) {
|
|
1241
|
+
const dir = rawDir.trim().replace(/^"(.*)"$/, '$1');
|
|
1242
|
+
if (!dir)
|
|
1243
|
+
continue;
|
|
1244
|
+
for (const name of names) {
|
|
1245
|
+
const candidate = path.join(dir, name);
|
|
1246
|
+
try {
|
|
1247
|
+
if ((await fs.stat(candidate)).isFile()) {
|
|
1248
|
+
executable = candidate;
|
|
1249
|
+
break;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
catch {
|
|
1253
|
+
/* not in this PATH directory */
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
if (executable)
|
|
1257
|
+
break;
|
|
1258
|
+
}
|
|
1259
|
+
ripgrepCache = { pathValue, executable };
|
|
1260
|
+
return executable;
|
|
1261
|
+
}
|
|
1160
1262
|
/**
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1263
|
+
* Fast content search using ripgrep's machine-readable stream. Hidden and
|
|
1264
|
+
* ignored paths remain included to preserve the filesystem tool's historical
|
|
1265
|
+
* semantics; callers can still narrow the root path for a smaller search.
|
|
1266
|
+
* Returning null asks the caller to use the portable Node fallback.
|
|
1164
1267
|
*/
|
|
1165
|
-
async function
|
|
1166
|
-
|
|
1268
|
+
async function searchContentWithRipgrep(executable, rootPath, needle, limit, signal, onProgress) {
|
|
1269
|
+
if (limit <= 0)
|
|
1270
|
+
return { matches: [], truncated: true };
|
|
1271
|
+
const args = [
|
|
1272
|
+
'--json',
|
|
1273
|
+
'--no-config',
|
|
1274
|
+
'--fixed-strings',
|
|
1275
|
+
'--ignore-case',
|
|
1276
|
+
'--hidden',
|
|
1277
|
+
'--no-ignore',
|
|
1278
|
+
'--max-filesize', String(SEARCH_MAX_FILE_BYTES),
|
|
1279
|
+
'--', needle, '.',
|
|
1280
|
+
];
|
|
1281
|
+
let child;
|
|
1167
1282
|
try {
|
|
1168
|
-
|
|
1283
|
+
child = spawn(executable, args, {
|
|
1284
|
+
cwd: rootPath,
|
|
1285
|
+
windowsHide: true,
|
|
1286
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1287
|
+
});
|
|
1169
1288
|
}
|
|
1170
1289
|
catch {
|
|
1171
|
-
return
|
|
1290
|
+
return null;
|
|
1172
1291
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1292
|
+
activeRipgrepChildren.add(child);
|
|
1293
|
+
let spawnError;
|
|
1294
|
+
let cancelled = false;
|
|
1295
|
+
let stderr = '';
|
|
1296
|
+
child.stderr?.on('data', (chunk) => {
|
|
1297
|
+
if (stderr.length < 8_000)
|
|
1298
|
+
stderr += String(chunk).slice(0, 8_000 - stderr.length);
|
|
1299
|
+
});
|
|
1300
|
+
const closed = new Promise((resolve) => {
|
|
1301
|
+
child.once('error', (error) => {
|
|
1302
|
+
spawnError = error;
|
|
1303
|
+
activeRipgrepChildren.delete(child);
|
|
1304
|
+
resolve(null);
|
|
1305
|
+
});
|
|
1306
|
+
child.once('close', (code) => {
|
|
1307
|
+
activeRipgrepChildren.delete(child);
|
|
1308
|
+
resolve(code);
|
|
1309
|
+
});
|
|
1310
|
+
});
|
|
1311
|
+
const onAbort = () => {
|
|
1312
|
+
cancelled = true;
|
|
1313
|
+
child.kill();
|
|
1314
|
+
};
|
|
1315
|
+
if (signal?.aborted)
|
|
1316
|
+
onAbort();
|
|
1317
|
+
else
|
|
1318
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1319
|
+
if (!child.stdout) {
|
|
1320
|
+
child.kill();
|
|
1321
|
+
await closed;
|
|
1322
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
const matches = [];
|
|
1326
|
+
let files = 0;
|
|
1327
|
+
let truncated = false;
|
|
1328
|
+
let parseFailed = false;
|
|
1329
|
+
const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
1176
1330
|
try {
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1331
|
+
for await (const line of lines) {
|
|
1332
|
+
if (!line)
|
|
1333
|
+
continue;
|
|
1334
|
+
let event;
|
|
1335
|
+
try {
|
|
1336
|
+
event = JSON.parse(line);
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
parseFailed = true;
|
|
1340
|
+
break;
|
|
1341
|
+
}
|
|
1342
|
+
if (event.type === 'begin') {
|
|
1343
|
+
files++;
|
|
1344
|
+
onProgress?.({ phase: 'searching file contents', files });
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if (event.type !== 'match')
|
|
1348
|
+
continue;
|
|
1349
|
+
const relativePath = event.data?.path?.text;
|
|
1350
|
+
const text = event.data?.lines?.text;
|
|
1351
|
+
const lineNumber = event.data?.line_number;
|
|
1352
|
+
if (typeof relativePath !== 'string' || typeof text !== 'string' || typeof lineNumber !== 'number') {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
matches.push({
|
|
1356
|
+
path: path.isAbsolute(relativePath) ? path.resolve(relativePath) : path.resolve(rootPath, relativePath),
|
|
1357
|
+
line: lineNumber,
|
|
1358
|
+
text: text.replace(/\r?\n$/, '').slice(0, 400),
|
|
1359
|
+
});
|
|
1360
|
+
onProgress?.({ phase: 'searching file contents', files, matches: matches.length });
|
|
1361
|
+
if (matches.length >= limit) {
|
|
1362
|
+
truncated = true;
|
|
1363
|
+
child.kill();
|
|
1364
|
+
break;
|
|
1365
|
+
}
|
|
1184
1366
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1367
|
+
}
|
|
1368
|
+
finally {
|
|
1369
|
+
lines.close();
|
|
1370
|
+
}
|
|
1371
|
+
if (parseFailed)
|
|
1372
|
+
child.kill();
|
|
1373
|
+
const exitCode = await closed;
|
|
1374
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1375
|
+
if (cancelled)
|
|
1376
|
+
throw new Error('Filesystem search cancelled.');
|
|
1377
|
+
// ripgrep uses 0 for matches and 1 for a clean no-match result. A deliberate
|
|
1378
|
+
// limit kill is also successful because the requested result budget is full.
|
|
1379
|
+
if (spawnError || parseFailed || (!truncated && exitCode !== 0 && exitCode !== 1)) {
|
|
1380
|
+
log.debug('ripgrep search failed; using Node fallback', {
|
|
1381
|
+
error: spawnError?.message,
|
|
1382
|
+
exitCode,
|
|
1383
|
+
stderr: stderr.trim(),
|
|
1384
|
+
});
|
|
1385
|
+
return null;
|
|
1386
|
+
}
|
|
1387
|
+
return { matches, truncated };
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Portable one-file scanner. One file descriptor provides the stat, binary
|
|
1391
|
+
* probe, and text stream; the previous implementation opened every candidate
|
|
1392
|
+
* twice in addition to a separate path stat.
|
|
1393
|
+
*/
|
|
1394
|
+
async function scanFileContent(full, needle, onMatch, shouldStop, signal) {
|
|
1395
|
+
let handle;
|
|
1396
|
+
let stream;
|
|
1397
|
+
let lines;
|
|
1398
|
+
const onAbort = () => {
|
|
1399
|
+
lines?.close();
|
|
1400
|
+
stream?.destroy(new Error('Filesystem search cancelled.'));
|
|
1401
|
+
};
|
|
1402
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1403
|
+
try {
|
|
1404
|
+
handle = await fs.open(full, 'r');
|
|
1405
|
+
const st = await handle.stat();
|
|
1406
|
+
if (!st.isFile() || st.size > SEARCH_MAX_FILE_BYTES || shouldStop())
|
|
1407
|
+
return;
|
|
1408
|
+
const probe = Buffer.alloc(Math.min(8_000, st.size));
|
|
1409
|
+
if (probe.length)
|
|
1410
|
+
await handle.read(probe, 0, probe.length, 0);
|
|
1411
|
+
if (looksBinary(probe) || shouldStop())
|
|
1412
|
+
return;
|
|
1413
|
+
const lower = needle.toLowerCase();
|
|
1414
|
+
stream = handle.createReadStream({ encoding: 'utf8', start: 0, autoClose: false });
|
|
1415
|
+
lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1416
|
+
let lineNo = 0;
|
|
1417
|
+
for await (const line of lines) {
|
|
1418
|
+
if (shouldStop())
|
|
1419
|
+
break;
|
|
1420
|
+
lineNo++;
|
|
1421
|
+
if (line.toLowerCase().includes(lower)
|
|
1422
|
+
&& !onMatch({ path: full, line: lineNo, text: line.slice(0, 400) })) {
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1187
1425
|
}
|
|
1188
1426
|
}
|
|
1189
1427
|
catch {
|
|
1190
|
-
|
|
1428
|
+
// Unreadable or concurrently removed files are skipped, matching the old search behavior.
|
|
1191
1429
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
const namePattern = typeof args.namePattern === 'string' ? args.namePattern.toLowerCase() : '';
|
|
1198
|
-
const contentPattern = typeof args.content === 'string' ? args.content : '';
|
|
1199
|
-
if (!namePattern && !contentPattern) {
|
|
1200
|
-
return errorResult('Provide "namePattern" and/or "content" to search for.');
|
|
1430
|
+
finally {
|
|
1431
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1432
|
+
lines?.close();
|
|
1433
|
+
stream?.destroy();
|
|
1434
|
+
await handle?.close().catch(() => undefined);
|
|
1201
1435
|
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1436
|
+
}
|
|
1437
|
+
/** Dirent traversal plus a bounded, globally-budgeted content worker pool. */
|
|
1438
|
+
async function searchWithNode(rootPath, namePattern, contentPattern, limit, signal, onProgress) {
|
|
1439
|
+
const nameMatches = [];
|
|
1440
|
+
const contentMatches = [];
|
|
1441
|
+
const active = new Set();
|
|
1442
|
+
let nameLimitReached = false;
|
|
1443
|
+
let contentLimitReached = false;
|
|
1444
|
+
let directoriesScanned = 0;
|
|
1445
|
+
let filesScanned = 0;
|
|
1446
|
+
const shouldStopContent = () => signal?.aborted === true || nameLimitReached || contentMatches.length >= limit;
|
|
1447
|
+
const scheduleContentScan = async (full) => {
|
|
1448
|
+
if (!contentPattern || shouldStopContent())
|
|
1209
1449
|
return;
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
names = await fs.readdir(dir);
|
|
1450
|
+
while (active.size >= SEARCH_CONCURRENCY && !shouldStopContent()) {
|
|
1451
|
+
await Promise.race(active);
|
|
1213
1452
|
}
|
|
1214
|
-
|
|
1453
|
+
if (shouldStopContent())
|
|
1215
1454
|
return;
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
matches.push({ path: full });
|
|
1224
|
-
if (matches.length >= MAX_SEARCH_RESULTS) {
|
|
1225
|
-
truncated = true;
|
|
1226
|
-
return;
|
|
1227
|
-
}
|
|
1455
|
+
const task = scanFileContent(full, contentPattern, (match) => {
|
|
1456
|
+
if (contentMatches.length >= limit)
|
|
1457
|
+
return false;
|
|
1458
|
+
contentMatches.push(match);
|
|
1459
|
+
if (contentMatches.length >= limit) {
|
|
1460
|
+
contentLimitReached = true;
|
|
1461
|
+
return false;
|
|
1228
1462
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1463
|
+
return !nameLimitReached;
|
|
1464
|
+
}, shouldStopContent, signal).finally(() => active.delete(task));
|
|
1465
|
+
active.add(task);
|
|
1466
|
+
};
|
|
1467
|
+
// Bounded breadth-first traversal. The former recursive walk awaited every
|
|
1468
|
+
// directory serially, which made name-only searches crawl on workspaces with
|
|
1469
|
+
// many small folders even though the filesystem can serve independent reads
|
|
1470
|
+
// concurrently.
|
|
1471
|
+
const directories = [rootPath];
|
|
1472
|
+
while (directories.length > 0
|
|
1473
|
+
&& !signal?.aborted
|
|
1474
|
+
&& !nameLimitReached
|
|
1475
|
+
&& (namePattern || !contentLimitReached)) {
|
|
1476
|
+
const batch = directories.splice(0, SEARCH_DIRECTORY_CONCURRENCY);
|
|
1477
|
+
const listings = await Promise.all(batch.map(async (dir) => {
|
|
1478
|
+
try {
|
|
1479
|
+
return { dir, entries: await fs.readdir(dir, { withFileTypes: true }) };
|
|
1231
1480
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1481
|
+
catch {
|
|
1482
|
+
return { dir, entries: [] };
|
|
1234
1483
|
}
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1484
|
+
}));
|
|
1485
|
+
directoriesScanned += listings.length;
|
|
1486
|
+
onProgress?.({
|
|
1487
|
+
phase: contentPattern ? 'walking directories and scanning files' : 'searching names',
|
|
1488
|
+
directories: directoriesScanned,
|
|
1489
|
+
files: filesScanned,
|
|
1490
|
+
matches: nameMatches.length + contentMatches.length,
|
|
1491
|
+
});
|
|
1492
|
+
for (const { dir, entries } of listings) {
|
|
1493
|
+
for (const entry of entries) {
|
|
1494
|
+
if (signal?.aborted || nameLimitReached || (!namePattern && contentLimitReached))
|
|
1495
|
+
break;
|
|
1496
|
+
const full = path.join(dir, entry.name);
|
|
1497
|
+
if (namePattern && entry.name.toLowerCase().includes(namePattern)) {
|
|
1498
|
+
nameMatches.push({ path: full });
|
|
1499
|
+
onProgress?.({ matches: nameMatches.length + contentMatches.length });
|
|
1500
|
+
if (nameMatches.length >= limit) {
|
|
1501
|
+
nameLimitReached = true;
|
|
1248
1502
|
break;
|
|
1249
1503
|
}
|
|
1250
1504
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1505
|
+
let isDirectory = entry.isDirectory();
|
|
1506
|
+
let isFile = entry.isFile();
|
|
1507
|
+
// Dirent normally carries the type on Windows/macOS/Linux. Fall back only
|
|
1508
|
+
// for filesystems that report an unknown type; never follow symlinks.
|
|
1509
|
+
if (!isDirectory && !isFile && !entry.isSymbolicLink()) {
|
|
1510
|
+
const type = await entryType(full);
|
|
1511
|
+
isDirectory = type === 'directory';
|
|
1512
|
+
isFile = type === 'file';
|
|
1513
|
+
}
|
|
1514
|
+
if (isDirectory) {
|
|
1515
|
+
directories.push(full);
|
|
1516
|
+
}
|
|
1517
|
+
else if (isFile) {
|
|
1518
|
+
filesScanned++;
|
|
1519
|
+
if (contentPattern && !contentLimitReached)
|
|
1520
|
+
await scheduleContentScan(full);
|
|
1521
|
+
}
|
|
1253
1522
|
}
|
|
1254
1523
|
}
|
|
1255
1524
|
}
|
|
1256
|
-
|
|
1525
|
+
await Promise.all(active);
|
|
1526
|
+
onProgress?.({
|
|
1527
|
+
directories: directoriesScanned,
|
|
1528
|
+
files: filesScanned,
|
|
1529
|
+
matches: nameMatches.length + contentMatches.length,
|
|
1530
|
+
}, true);
|
|
1531
|
+
const matches = [...nameMatches, ...contentMatches].slice(0, limit);
|
|
1532
|
+
return {
|
|
1533
|
+
matches,
|
|
1534
|
+
truncated: nameLimitReached || contentLimitReached || nameMatches.length + contentMatches.length >= limit,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
async function searchTool(args, roots, context) {
|
|
1538
|
+
const signal = context?.signal;
|
|
1539
|
+
if (signal?.aborted)
|
|
1540
|
+
throw new Error('Filesystem search cancelled.');
|
|
1541
|
+
const rootPath = await resolvePath(args.path, roots);
|
|
1542
|
+
const namePattern = typeof args.namePattern === 'string' ? args.namePattern.toLowerCase() : '';
|
|
1543
|
+
const contentPattern = typeof args.content === 'string' ? args.content : '';
|
|
1544
|
+
if (!namePattern && !contentPattern) {
|
|
1545
|
+
return errorResult('Provide "namePattern" and/or "content" to search for.');
|
|
1546
|
+
}
|
|
1547
|
+
const progress = createSearchProgressReporter(context);
|
|
1548
|
+
progress.update({ phase: 'starting filesystem search' }, true);
|
|
1549
|
+
try {
|
|
1550
|
+
const ripgrep = contentPattern ? await resolveRipgrepExecutable() : null;
|
|
1551
|
+
if (!ripgrep) {
|
|
1552
|
+
const result = await searchWithNode(rootPath, namePattern, contentPattern, MAX_SEARCH_RESULTS, signal, progress.update);
|
|
1553
|
+
if (signal?.aborted)
|
|
1554
|
+
throw new Error('Filesystem search cancelled.');
|
|
1555
|
+
progress.update({ phase: 'filesystem search complete', matches: result.matches.length }, true);
|
|
1556
|
+
return dualResult(result);
|
|
1557
|
+
}
|
|
1558
|
+
// Name matches retain their historical priority in the combined response.
|
|
1559
|
+
const names = namePattern
|
|
1560
|
+
? await searchWithNode(rootPath, namePattern, '', MAX_SEARCH_RESULTS, signal, progress.update)
|
|
1561
|
+
: { matches: [], truncated: false };
|
|
1562
|
+
if (signal?.aborted)
|
|
1563
|
+
throw new Error('Filesystem search cancelled.');
|
|
1564
|
+
if (names.truncated)
|
|
1565
|
+
return dualResult(names);
|
|
1566
|
+
const remaining = MAX_SEARCH_RESULTS - names.matches.length;
|
|
1567
|
+
const content = await searchContentWithRipgrep(ripgrep, rootPath, contentPattern, remaining, signal, progress.update);
|
|
1568
|
+
if (content) {
|
|
1569
|
+
progress.update({
|
|
1570
|
+
phase: 'filesystem search complete',
|
|
1571
|
+
matches: names.matches.length + content.matches.length,
|
|
1572
|
+
}, true);
|
|
1573
|
+
return dualResult({
|
|
1574
|
+
matches: [...names.matches, ...content.matches],
|
|
1575
|
+
truncated: content.truncated || names.matches.length + content.matches.length >= MAX_SEARCH_RESULTS,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
const fallback = await searchWithNode(rootPath, '', contentPattern, remaining, signal, progress.update);
|
|
1579
|
+
if (signal?.aborted)
|
|
1580
|
+
throw new Error('Filesystem search cancelled.');
|
|
1581
|
+
return dualResult({
|
|
1582
|
+
matches: [...names.matches, ...fallback.matches],
|
|
1583
|
+
truncated: fallback.truncated || names.matches.length + fallback.matches.length >= MAX_SEARCH_RESULTS,
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
finally {
|
|
1587
|
+
await progress.stop();
|
|
1588
|
+
}
|
|
1257
1589
|
}
|
|
1258
1590
|
async function getFileInfoTool(args, roots) {
|
|
1259
1591
|
const target = await resolvePath(args.path, roots);
|
|
@@ -1289,7 +1621,14 @@ async function deleteTool(args, roots) {
|
|
|
1289
1621
|
async function getAllowedDirectoriesTool(roots) {
|
|
1290
1622
|
return dualResult({ directories: roots });
|
|
1291
1623
|
}
|
|
1292
|
-
export async function filesystemCallTool(toolName, args, callerNodeId) {
|
|
1624
|
+
export async function filesystemCallTool(toolName, args, callerNodeId, contextOrSignal) {
|
|
1625
|
+
// Backwards-compatible direct-call shape: older callers/tests passed the
|
|
1626
|
+
// AbortSignal itself as argument four.
|
|
1627
|
+
const context = contextOrSignal
|
|
1628
|
+
? ('aborted' in contextOrSignal
|
|
1629
|
+
? { signal: contextOrSignal }
|
|
1630
|
+
: contextOrSignal)
|
|
1631
|
+
: undefined;
|
|
1293
1632
|
try {
|
|
1294
1633
|
// MCP App launcher (#97): pure UI trigger — returns immediately without
|
|
1295
1634
|
// touching the filesystem. The app renders in chat; the user's pick returns
|
|
@@ -1310,7 +1649,7 @@ export async function filesystemCallTool(toolName, args, callerNodeId) {
|
|
|
1310
1649
|
case 'dir_tree':
|
|
1311
1650
|
return await dirTreeTool(args, roots);
|
|
1312
1651
|
case 'search':
|
|
1313
|
-
return await searchTool(args, roots);
|
|
1652
|
+
return await searchTool(args, roots, context);
|
|
1314
1653
|
case 'get_file_info':
|
|
1315
1654
|
return await getFileInfoTool(args, roots);
|
|
1316
1655
|
case 'create_directory':
|