@j0hanz/filesystem-mcp 1.7.3 → 1.8.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/dist/lib/file-operations/glob-helpers.d.ts +18 -0
- package/dist/lib/file-operations/glob-helpers.js +23 -0
- package/dist/lib/file-operations/list-directory.js +4 -8
- package/dist/lib/file-operations/search-content.d.ts +1 -8
- package/dist/lib/file-operations/search-content.js +29 -100
- package/dist/lib/file-operations/search-files.js +20 -22
- package/dist/lib/file-operations/search-matcher.d.ts +10 -0
- package/dist/lib/file-operations/search-matcher.js +72 -0
- package/dist/lib/file-operations/search-worker.js +3 -1
- package/dist/lib/file-operations/tree.js +12 -16
- package/dist/lib/fs-helpers.d.ts +1 -0
- package/dist/lib/fs-helpers.js +9 -0
- package/dist/lib/option-utils.d.ts +3 -0
- package/dist/lib/option-utils.js +15 -0
- package/dist/lib/path-validation.d.ts +1 -0
- package/dist/lib/path-validation.js +7 -0
- package/dist/lib/progress-reporting.d.ts +11 -0
- package/dist/lib/progress-reporting.js +13 -0
- package/dist/tools/apply-patch.js +3 -0
- package/dist/tools/calculate-hash.js +12 -12
- package/dist/tools/delete-file.js +5 -2
- package/dist/tools/read-multiple.js +10 -22
- package/dist/tools/replace-in-files.js +10 -11
- package/dist/tools/search-content.js +2 -2
- package/dist/tools/shared.d.ts +16 -0
- package/dist/tools/shared.js +37 -18
- package/dist/tools/stat-many.js +10 -21
- package/package.json +6 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { globEntries } from './glob-engine.js';
|
|
2
|
+
export interface GlobConfig {
|
|
3
|
+
cwd: string;
|
|
4
|
+
pattern: string;
|
|
5
|
+
excludePatterns?: readonly string[];
|
|
6
|
+
includeHidden?: boolean;
|
|
7
|
+
baseNameMatch?: boolean;
|
|
8
|
+
caseSensitiveMatch?: boolean;
|
|
9
|
+
followSymbolicLinks?: boolean;
|
|
10
|
+
onlyFiles?: boolean;
|
|
11
|
+
stats?: boolean;
|
|
12
|
+
maxDepth?: number;
|
|
13
|
+
suppressErrors?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Builds standard options for globEntries to ensure consistency across search tools.
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildGlobOptions(config: GlobConfig): Parameters<typeof globEntries>[0];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds standard options for globEntries to ensure consistency across search tools.
|
|
3
|
+
*/
|
|
4
|
+
export function buildGlobOptions(config) {
|
|
5
|
+
const options = {
|
|
6
|
+
cwd: config.cwd,
|
|
7
|
+
pattern: config.pattern,
|
|
8
|
+
excludePatterns: config.excludePatterns ?? [],
|
|
9
|
+
includeHidden: config.includeHidden ?? false,
|
|
10
|
+
baseNameMatch: config.baseNameMatch ?? false,
|
|
11
|
+
caseSensitiveMatch: config.caseSensitiveMatch ?? true,
|
|
12
|
+
followSymbolicLinks: config.followSymbolicLinks ?? false,
|
|
13
|
+
onlyFiles: config.onlyFiles ?? true,
|
|
14
|
+
stats: config.stats ?? false,
|
|
15
|
+
};
|
|
16
|
+
if (config.suppressErrors) {
|
|
17
|
+
options.suppressErrors = config.suppressErrors;
|
|
18
|
+
}
|
|
19
|
+
if (config.maxDepth !== undefined) {
|
|
20
|
+
options.maxDepth = config.maxDepth;
|
|
21
|
+
}
|
|
22
|
+
return options;
|
|
23
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fsp from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { DEFAULT_LIST_MAX_ENTRIES, DEFAULT_MAX_DEPTH, DEFAULT_SEARCH_TIMEOUT_MS, PARALLEL_CONCURRENCY, } from '../constants.js';
|
|
4
|
-
import {
|
|
4
|
+
import { isHidden, processInParallel, withAbort, withTimedAbortSignal, } from '../fs-helpers.js';
|
|
5
5
|
import { isSensitivePath } from '../path-policy.js';
|
|
6
6
|
import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
|
|
7
7
|
import { isEntryAccessibleByType, needsStatsForSort, resolveEntryType, resolveStopReason, withOptionalStoppedReason, } from './common.js';
|
|
@@ -244,13 +244,9 @@ async function executeListDirectory(basePath, normalized, signal) {
|
|
|
244
244
|
}
|
|
245
245
|
export async function listDirectory(dirPath, options = {}) {
|
|
246
246
|
const normalized = normalizeOptions(options);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
try {
|
|
247
|
+
return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
|
|
248
|
+
const basePath = await validateExistingDirectory(dirPath, signal);
|
|
250
249
|
const { entries, summary } = await executeListDirectory(basePath, normalized, signal);
|
|
251
250
|
return { path: basePath, entries, summary };
|
|
252
|
-
}
|
|
253
|
-
finally {
|
|
254
|
-
cleanup();
|
|
255
|
-
}
|
|
251
|
+
});
|
|
256
252
|
}
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import * as fsp from 'node:fs/promises';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { ContentMatch, SearchContentResult } from '../../config.js';
|
|
4
|
-
|
|
5
|
-
caseSensitive: z.ZodBoolean;
|
|
6
|
-
wholeWord: z.ZodBoolean;
|
|
7
|
-
isLiteral: z.ZodBoolean;
|
|
8
|
-
}, z.core.$strict>;
|
|
9
|
-
export type MatcherOptions = z.infer<typeof MatcherOptionsSchema>;
|
|
4
|
+
import type { Matcher, MatcherOptions } from './search-matcher.js';
|
|
10
5
|
export interface ScanFileOptions {
|
|
11
6
|
maxFileSize: number;
|
|
12
7
|
skipBinary: boolean;
|
|
@@ -36,8 +31,6 @@ export interface SearchContentOptions extends Partial<ResolvedOptions> {
|
|
|
36
31
|
current: number;
|
|
37
32
|
}) => void;
|
|
38
33
|
}
|
|
39
|
-
export type Matcher = (line: string) => number;
|
|
40
|
-
export declare function buildMatcher(pattern: string, options: MatcherOptions): Matcher;
|
|
41
34
|
type BinaryDetector = (resolvedPath: string, handle: fsp.FileHandle, signal?: AbortSignal) => Promise<boolean>;
|
|
42
35
|
export interface ScanRequest {
|
|
43
36
|
type: 'scan';
|
|
@@ -3,22 +3,19 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
4
|
import { Worker } from 'node:worker_threads';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import RE2 from 're2';
|
|
7
|
-
import safeRegex from 'safe-regex2';
|
|
8
6
|
import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_SEARCH_MAX_FILES, DEFAULT_SEARCH_TIMEOUT_MS, MAX_LINE_CONTENT_LENGTH, MAX_SEARCHABLE_FILE_SIZE, SEARCH_WORKERS, } from '../constants.js';
|
|
9
7
|
import { ErrorCode, formatUnknownErrorMessage, isTimeoutLikeError, McpError, } from '../errors.js';
|
|
10
|
-
import { assertNotAborted,
|
|
8
|
+
import { assertNotAborted, isProbablyBinary, withAbort, withTimedAbortSignal, } from '../fs-helpers.js';
|
|
9
|
+
import { mergeOptions, omitOptionKeys } from '../option-utils.js';
|
|
11
10
|
import { assertAllowedFileAccess, isSensitivePath } from '../path-policy.js';
|
|
12
11
|
import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
|
|
12
|
+
import { reportPeriodicProgress } from '../progress-reporting.js';
|
|
13
13
|
import { withOptionalStoppedReason } from './common.js';
|
|
14
14
|
import { globEntries } from './glob-engine.js';
|
|
15
|
+
import { buildGlobOptions } from './glob-helpers.js';
|
|
16
|
+
import { buildMatcher, validatePattern } from './search-matcher.js';
|
|
15
17
|
// --- Configuration & Schemas ---
|
|
16
18
|
const INTERNAL_MAX_RESULTS = 500;
|
|
17
|
-
export const MatcherOptionsSchema = z.strictObject({
|
|
18
|
-
caseSensitive: z.boolean(),
|
|
19
|
-
wholeWord: z.boolean(),
|
|
20
|
-
isLiteral: z.boolean(),
|
|
21
|
-
});
|
|
22
19
|
const SearchOptionsSchema = z.strictObject({
|
|
23
20
|
filePattern: z.string().min(1),
|
|
24
21
|
excludePatterns: z.array(z.string()),
|
|
@@ -55,80 +52,14 @@ const ERROR_SCAN_CANCELLED = 'Scan cancelled';
|
|
|
55
52
|
const ERROR_WORKER_POOL_CLOSED = 'Worker pool closed';
|
|
56
53
|
// --- Helpers ---
|
|
57
54
|
function resolveOptions(options) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
delete rest.onProgress;
|
|
61
|
-
const merged = { ...DEFAULTS, ...rest };
|
|
55
|
+
const normalizedOptions = omitOptionKeys(options, ['signal', 'onProgress']);
|
|
56
|
+
const merged = mergeOptions(DEFAULTS, normalizedOptions);
|
|
62
57
|
const result = SearchOptionsSchema.safeParse(merged);
|
|
63
58
|
if (!result.success) {
|
|
64
59
|
throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid search options: ${result.error.message}`, undefined, { errors: z.treeifyError(result.error) });
|
|
65
60
|
}
|
|
66
61
|
return result.data;
|
|
67
62
|
}
|
|
68
|
-
function countRegexLineMatches(regex, line) {
|
|
69
|
-
regex.lastIndex = 0;
|
|
70
|
-
let count = 0;
|
|
71
|
-
while (regex.exec(line) !== null) {
|
|
72
|
-
count++;
|
|
73
|
-
if (regex.lastIndex === 0)
|
|
74
|
-
regex.lastIndex++;
|
|
75
|
-
}
|
|
76
|
-
return count;
|
|
77
|
-
}
|
|
78
|
-
function escapeLiteral(pattern) {
|
|
79
|
-
return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
80
|
-
}
|
|
81
|
-
function buildRegexPattern(pattern, options) {
|
|
82
|
-
const escaped = options.isLiteral ? escapeLiteral(pattern) : pattern;
|
|
83
|
-
return options.wholeWord ? `\\b${escaped}\\b` : escaped;
|
|
84
|
-
}
|
|
85
|
-
function validatePattern(pattern, options) {
|
|
86
|
-
if (options.isLiteral && pattern.length === 0)
|
|
87
|
-
return;
|
|
88
|
-
if (options.isLiteral && !options.wholeWord)
|
|
89
|
-
return;
|
|
90
|
-
const final = buildRegexPattern(pattern, options);
|
|
91
|
-
if (!safeRegex(final)) {
|
|
92
|
-
throw new Error(`Potentially unsafe regular expression (ReDoS risk): ${pattern}`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
function buildLiteralMatcher(pattern, options) {
|
|
96
|
-
if (!options.caseSensitive) {
|
|
97
|
-
const final = escapeLiteral(pattern);
|
|
98
|
-
const regex = new RegExp(final, 'gi');
|
|
99
|
-
return (line) => countRegexLineMatches(regex, line);
|
|
100
|
-
}
|
|
101
|
-
// Fast path for case-sensitive literal
|
|
102
|
-
const needle = pattern;
|
|
103
|
-
if (needle.length === 0)
|
|
104
|
-
return () => 0;
|
|
105
|
-
return (line) => {
|
|
106
|
-
if (line.length === 0)
|
|
107
|
-
return 0;
|
|
108
|
-
let count = 0;
|
|
109
|
-
let pos = line.indexOf(needle);
|
|
110
|
-
while (pos !== -1) {
|
|
111
|
-
count++;
|
|
112
|
-
pos = line.indexOf(needle, pos + needle.length);
|
|
113
|
-
}
|
|
114
|
-
return count;
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
function buildRegexMatcher(final, caseSensitive) {
|
|
118
|
-
const regex = new RE2(final, caseSensitive ? 'g' : 'gi');
|
|
119
|
-
return (line) => countRegexLineMatches(regex, line);
|
|
120
|
-
}
|
|
121
|
-
export function buildMatcher(pattern, options) {
|
|
122
|
-
if (options.isLiteral && pattern.length === 0)
|
|
123
|
-
return () => 0;
|
|
124
|
-
if (options.isLiteral && !options.wholeWord) {
|
|
125
|
-
// fast path for simple literal search
|
|
126
|
-
return buildLiteralMatcher(pattern, options);
|
|
127
|
-
}
|
|
128
|
-
const final = buildRegexPattern(pattern, options);
|
|
129
|
-
validatePattern(pattern, options); // Re-validate to be safe
|
|
130
|
-
return buildRegexMatcher(final, options.caseSensitive);
|
|
131
|
-
}
|
|
132
63
|
/**
|
|
133
64
|
* Manages a sliding window of lines and pending context-after buffers.
|
|
134
65
|
*/
|
|
@@ -562,13 +493,6 @@ function processScanResult(winner, summary, matches, maxResults) {
|
|
|
562
493
|
}
|
|
563
494
|
}
|
|
564
495
|
}
|
|
565
|
-
function reportSearchProgress(onProgress, current, total, force = false) {
|
|
566
|
-
if (!onProgress || current === 0)
|
|
567
|
-
return;
|
|
568
|
-
if (!force && current % 25 !== 0)
|
|
569
|
-
return;
|
|
570
|
-
onProgress({ current, total });
|
|
571
|
-
}
|
|
572
496
|
async function waitForWinner(pending) {
|
|
573
497
|
const raceCandidates = [];
|
|
574
498
|
for (const task of pending) {
|
|
@@ -659,7 +583,7 @@ async function searchSingleFile(details, opts, pattern, signal) {
|
|
|
659
583
|
async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
660
584
|
const root = await validateExistingDirectory(details.resolvedPath, signal);
|
|
661
585
|
const rootDirectories = [root];
|
|
662
|
-
const stream = globEntries({
|
|
586
|
+
const stream = globEntries(buildGlobOptions({
|
|
663
587
|
cwd: root,
|
|
664
588
|
pattern: opts.filePattern,
|
|
665
589
|
excludePatterns: opts.excludePatterns,
|
|
@@ -670,7 +594,7 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
|
670
594
|
onlyFiles: true,
|
|
671
595
|
stats: false,
|
|
672
596
|
suppressErrors: true,
|
|
673
|
-
});
|
|
597
|
+
}));
|
|
674
598
|
async function* fileGenerator() {
|
|
675
599
|
let scanned = 0;
|
|
676
600
|
for await (const entry of stream) {
|
|
@@ -686,10 +610,17 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
|
686
610
|
if (isSensitivePath(entry.path, normalized))
|
|
687
611
|
continue;
|
|
688
612
|
scanned++;
|
|
689
|
-
|
|
613
|
+
reportPeriodicProgress(onProgress, scanned, {
|
|
614
|
+
total: opts.maxFilesScanned,
|
|
615
|
+
throttleModulo: 25,
|
|
616
|
+
});
|
|
690
617
|
yield { resolvedPath: normalized, requestedPath: entry.path };
|
|
691
618
|
}
|
|
692
|
-
|
|
619
|
+
reportPeriodicProgress(onProgress, scanned, {
|
|
620
|
+
total: opts.maxFilesScanned,
|
|
621
|
+
throttleModulo: 25,
|
|
622
|
+
force: true,
|
|
623
|
+
});
|
|
693
624
|
}
|
|
694
625
|
const summary = createScanSummary();
|
|
695
626
|
const resolvedStream = fileGenerator();
|
|
@@ -725,17 +656,18 @@ export async function searchContent(basePath, pattern, options = {}) {
|
|
|
725
656
|
if (typeof pattern !== 'string')
|
|
726
657
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'pattern required');
|
|
727
658
|
const opts = resolveOptions(options);
|
|
728
|
-
const { signal, cleanup } = createTimedAbortSignal(options.signal, opts.timeoutMs);
|
|
729
659
|
try {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
660
|
+
return await withTimedAbortSignal(options.signal, opts.timeoutMs, async (signal) => {
|
|
661
|
+
const details = await validateExistingPathDetailed(basePath, signal);
|
|
662
|
+
const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
|
|
663
|
+
if (stats.isFile()) {
|
|
664
|
+
return searchSingleFile(details, opts, pattern, signal);
|
|
665
|
+
}
|
|
666
|
+
if (!stats.isDirectory()) {
|
|
667
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Path must be file or directory', basePath);
|
|
668
|
+
}
|
|
669
|
+
return searchDirectory(details, opts, pattern, signal, options.onProgress);
|
|
670
|
+
});
|
|
739
671
|
}
|
|
740
672
|
catch (error) {
|
|
741
673
|
if (isTimeoutLikeError(error)) {
|
|
@@ -743,7 +675,4 @@ export async function searchContent(basePath, pattern, options = {}) {
|
|
|
743
675
|
}
|
|
744
676
|
throw error;
|
|
745
677
|
}
|
|
746
|
-
finally {
|
|
747
|
-
cleanup();
|
|
748
|
-
}
|
|
749
678
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import { DEFAULT_SEARCH_MAX_FILES, DEFAULT_SEARCH_TIMEOUT_MS, } from '../constants.js';
|
|
3
|
-
import {
|
|
3
|
+
import { withTimedAbortSignal } from '../fs-helpers.js';
|
|
4
4
|
import { isSensitivePath } from '../path-policy.js';
|
|
5
5
|
import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
|
|
6
|
+
import { reportPeriodicProgress } from '../progress-reporting.js';
|
|
6
7
|
import { compareOptionalNumberDesc, compareStringValues, isEntryAccessibleByType, needsStatsForSort, resolveEntryType, resolveStopReason, stableSortByDerivedString, withOptionalStoppedReason, } from './common.js';
|
|
7
8
|
import { isIgnoredByGitignore, loadRootGitignore } from './gitignore.js';
|
|
8
9
|
import { globEntries } from './glob-engine.js';
|
|
10
|
+
import { buildGlobOptions } from './glob-helpers.js';
|
|
9
11
|
// Internal default for find tool - not exposed to MCP users
|
|
10
12
|
const INTERNAL_MAX_RESULTS = 1000;
|
|
11
13
|
function normalizeOptions(options) {
|
|
@@ -68,7 +70,7 @@ function createCollectState() {
|
|
|
68
70
|
};
|
|
69
71
|
}
|
|
70
72
|
function buildSearchStream(root, pattern, excludePatterns, normalized, needsStats) {
|
|
71
|
-
const options = {
|
|
73
|
+
const options = buildGlobOptions({
|
|
72
74
|
cwd: root,
|
|
73
75
|
pattern,
|
|
74
76
|
excludePatterns,
|
|
@@ -78,10 +80,10 @@ function buildSearchStream(root, pattern, excludePatterns, normalized, needsStat
|
|
|
78
80
|
followSymbolicLinks: false,
|
|
79
81
|
onlyFiles: true,
|
|
80
82
|
stats: needsStats,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
83
|
+
...(normalized.maxDepth !== undefined
|
|
84
|
+
? { maxDepth: normalized.maxDepth }
|
|
85
|
+
: {}),
|
|
86
|
+
});
|
|
85
87
|
return globEntries(options);
|
|
86
88
|
}
|
|
87
89
|
function buildCollectResult(state) {
|
|
@@ -103,19 +105,15 @@ function handleEntry(entry, entryType, needsStats, normalized, state) {
|
|
|
103
105
|
state.stoppedReason = 'maxResults';
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
|
-
function reportSearchFilesProgress(onProgress, current, total, force = false) {
|
|
107
|
-
if (!onProgress || current === 0)
|
|
108
|
-
return;
|
|
109
|
-
if (!force && current % 25 !== 0)
|
|
110
|
-
return;
|
|
111
|
-
onProgress({ current, total });
|
|
112
|
-
}
|
|
113
108
|
async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress) {
|
|
114
109
|
for await (const entry of stream) {
|
|
115
110
|
if (shouldStopCollecting(state, normalized, signal))
|
|
116
111
|
break;
|
|
117
112
|
state.filesScanned++;
|
|
118
|
-
|
|
113
|
+
reportPeriodicProgress(onProgress, state.filesScanned, {
|
|
114
|
+
total: normalized.maxFilesScanned,
|
|
115
|
+
throttleModulo: 25,
|
|
116
|
+
});
|
|
119
117
|
if (isEntryIgnoredByGitignore(gitignoreMatcher, root, entry.path, entry.relativePath)) {
|
|
120
118
|
continue;
|
|
121
119
|
}
|
|
@@ -132,7 +130,11 @@ async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher
|
|
|
132
130
|
if (state.truncated)
|
|
133
131
|
break;
|
|
134
132
|
}
|
|
135
|
-
|
|
133
|
+
reportPeriodicProgress(onProgress, state.filesScanned, {
|
|
134
|
+
total: normalized.maxFilesScanned,
|
|
135
|
+
throttleModulo: 25,
|
|
136
|
+
force: true,
|
|
137
|
+
});
|
|
136
138
|
}
|
|
137
139
|
function isEntryIgnoredByGitignore(matcher, root, entryPath, relativePath) {
|
|
138
140
|
if (!matcher)
|
|
@@ -201,9 +203,8 @@ async function runSearchFiles(root, pattern, excludePatterns, normalized, signal
|
|
|
201
203
|
}
|
|
202
204
|
export async function searchFiles(basePath, pattern, excludePatterns = [], options = {}) {
|
|
203
205
|
const normalized = normalizeOptions(options);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
try {
|
|
206
|
+
return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
|
|
207
|
+
const root = await validateExistingDirectory(basePath, signal);
|
|
207
208
|
const { results, summary } = await runSearchFiles(root, pattern, excludePatterns, normalized, signal, options.onProgress);
|
|
208
209
|
return {
|
|
209
210
|
basePath: root,
|
|
@@ -211,8 +212,5 @@ export async function searchFiles(basePath, pattern, excludePatterns = [], optio
|
|
|
211
212
|
results,
|
|
212
213
|
summary,
|
|
213
214
|
};
|
|
214
|
-
}
|
|
215
|
-
finally {
|
|
216
|
-
cleanup();
|
|
217
|
-
}
|
|
215
|
+
});
|
|
218
216
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const MatcherOptionsSchema: z.ZodObject<{
|
|
3
|
+
caseSensitive: z.ZodBoolean;
|
|
4
|
+
wholeWord: z.ZodBoolean;
|
|
5
|
+
isLiteral: z.ZodBoolean;
|
|
6
|
+
}, z.core.$strict>;
|
|
7
|
+
export type MatcherOptions = z.infer<typeof MatcherOptionsSchema>;
|
|
8
|
+
export type Matcher = (line: string) => number;
|
|
9
|
+
export declare function validatePattern(pattern: string, options: MatcherOptions): void;
|
|
10
|
+
export declare function buildMatcher(pattern: string, options: MatcherOptions): Matcher;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import RE2 from 're2';
|
|
3
|
+
import safeRegex from 'safe-regex2';
|
|
4
|
+
export const MatcherOptionsSchema = z.strictObject({
|
|
5
|
+
caseSensitive: z.boolean(),
|
|
6
|
+
wholeWord: z.boolean(),
|
|
7
|
+
isLiteral: z.boolean(),
|
|
8
|
+
});
|
|
9
|
+
function countRegexLineMatches(regex, line) {
|
|
10
|
+
regex.lastIndex = 0;
|
|
11
|
+
let count = 0;
|
|
12
|
+
while (regex.exec(line) !== null) {
|
|
13
|
+
count++;
|
|
14
|
+
if (regex.lastIndex === 0)
|
|
15
|
+
regex.lastIndex++;
|
|
16
|
+
}
|
|
17
|
+
return count;
|
|
18
|
+
}
|
|
19
|
+
function escapeLiteral(pattern) {
|
|
20
|
+
return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
}
|
|
22
|
+
function buildRegexPattern(pattern, options) {
|
|
23
|
+
const escaped = options.isLiteral ? escapeLiteral(pattern) : pattern;
|
|
24
|
+
return options.wholeWord ? `\\b${escaped}\\b` : escaped;
|
|
25
|
+
}
|
|
26
|
+
export function validatePattern(pattern, options) {
|
|
27
|
+
if (options.isLiteral && pattern.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
if (options.isLiteral && !options.wholeWord)
|
|
30
|
+
return;
|
|
31
|
+
const final = buildRegexPattern(pattern, options);
|
|
32
|
+
if (!safeRegex(final)) {
|
|
33
|
+
throw new Error(`Potentially unsafe regular expression (ReDoS risk): ${pattern}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function buildLiteralMatcher(pattern, options) {
|
|
37
|
+
if (!options.caseSensitive) {
|
|
38
|
+
const final = escapeLiteral(pattern);
|
|
39
|
+
const regex = new RegExp(final, 'gi');
|
|
40
|
+
return (line) => countRegexLineMatches(regex, line);
|
|
41
|
+
}
|
|
42
|
+
// Fast path for case-sensitive literal
|
|
43
|
+
const needle = pattern;
|
|
44
|
+
if (needle.length === 0)
|
|
45
|
+
return () => 0;
|
|
46
|
+
return (line) => {
|
|
47
|
+
if (line.length === 0)
|
|
48
|
+
return 0;
|
|
49
|
+
let count = 0;
|
|
50
|
+
let pos = line.indexOf(needle);
|
|
51
|
+
while (pos !== -1) {
|
|
52
|
+
count++;
|
|
53
|
+
pos = line.indexOf(needle, pos + needle.length);
|
|
54
|
+
}
|
|
55
|
+
return count;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function buildRegexMatcher(final, caseSensitive) {
|
|
59
|
+
const regex = new RE2(final, caseSensitive ? 'g' : 'gi');
|
|
60
|
+
return (line) => countRegexLineMatches(regex, line);
|
|
61
|
+
}
|
|
62
|
+
export function buildMatcher(pattern, options) {
|
|
63
|
+
if (options.isLiteral && pattern.length === 0)
|
|
64
|
+
return () => 0;
|
|
65
|
+
if (options.isLiteral && !options.wholeWord) {
|
|
66
|
+
// fast path for simple literal search
|
|
67
|
+
return buildLiteralMatcher(pattern, options);
|
|
68
|
+
}
|
|
69
|
+
const final = buildRegexPattern(pattern, options);
|
|
70
|
+
validatePattern(pattern, options); // Re-validate to be safe
|
|
71
|
+
return buildRegexMatcher(final, options.caseSensitive);
|
|
72
|
+
}
|
|
@@ -2,7 +2,9 @@ import { parentPort, threadId, workerData } from 'node:worker_threads';
|
|
|
2
2
|
import { formatUnknownErrorMessage } from '../errors.js';
|
|
3
3
|
import { isProbablyBinary } from '../fs-helpers.js';
|
|
4
4
|
import { startPerfMeasure } from '../observability.js';
|
|
5
|
-
import {
|
|
5
|
+
import { scanFileInWorker } from './search-content.js';
|
|
6
|
+
import { buildMatcher } from './search-matcher.js';
|
|
7
|
+
import {} from './search-matcher.js';
|
|
6
8
|
const matcherCache = new Map();
|
|
7
9
|
const MAX_MATCHER_CACHE_SIZE = 100;
|
|
8
10
|
function getMatcherCacheKey(pattern, options) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_SEARCH_TIMEOUT_MS, } from '../constants.js';
|
|
3
|
-
import {
|
|
3
|
+
import { withTimedAbortSignal } from '../fs-helpers.js';
|
|
4
4
|
import { toPosixPath } from '../path-format.js';
|
|
5
5
|
import { isSensitivePath } from '../path-policy.js';
|
|
6
6
|
import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
|
|
@@ -194,17 +194,16 @@ export function formatTreeAscii(tree) {
|
|
|
194
194
|
}
|
|
195
195
|
export async function treeDirectory(dirPath, options = {}) {
|
|
196
196
|
const normalized = normalizeOptions(options);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
try {
|
|
197
|
+
return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
|
|
198
|
+
const root = await validateExistingDirectory(dirPath, signal);
|
|
199
|
+
const rootNormalized = normalizePath(root);
|
|
200
|
+
const rootDirectories = [rootNormalized];
|
|
201
|
+
const accessDeps = {
|
|
202
|
+
normalizePath,
|
|
203
|
+
isPathWithinDirectories,
|
|
204
|
+
isSensitivePath,
|
|
205
|
+
validateSymlinkPath: validateExistingPathDetailed,
|
|
206
|
+
};
|
|
208
207
|
const excludePatterns = normalized.includeIgnored
|
|
209
208
|
? []
|
|
210
209
|
: DEFAULT_EXCLUDE_PATTERNS;
|
|
@@ -262,8 +261,5 @@ export async function treeDirectory(dirPath, options = {}) {
|
|
|
262
261
|
truncated,
|
|
263
262
|
totalEntries,
|
|
264
263
|
};
|
|
265
|
-
}
|
|
266
|
-
finally {
|
|
267
|
-
cleanup();
|
|
268
|
-
}
|
|
264
|
+
});
|
|
269
265
|
}
|
package/dist/lib/fs-helpers.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare function createTimedAbortSignal(baseSignal: AbortSignal | undefin
|
|
|
7
7
|
signal: AbortSignal;
|
|
8
8
|
cleanup: () => void;
|
|
9
9
|
};
|
|
10
|
+
export declare function withTimedAbortSignal<T>(baseSignal: AbortSignal | undefined, timeoutMs: number | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
10
11
|
interface ParallelResult<R> {
|
|
11
12
|
results: R[];
|
|
12
13
|
errors: {
|
package/dist/lib/fs-helpers.js
CHANGED
|
@@ -110,6 +110,15 @@ export function createTimedAbortSignal(baseSignal, timeoutMs) {
|
|
|
110
110
|
}
|
|
111
111
|
return createNoopSignal();
|
|
112
112
|
}
|
|
113
|
+
export async function withTimedAbortSignal(baseSignal, timeoutMs, run) {
|
|
114
|
+
const { signal, cleanup } = createTimedAbortSignal(baseSignal, timeoutMs);
|
|
115
|
+
try {
|
|
116
|
+
return await run(signal);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
cleanup();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
113
122
|
function createNoopSignal() {
|
|
114
123
|
return { signal: SHARED_NOOP_SIGNAL, cleanup: () => { } };
|
|
115
124
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function mergeOptions<T extends object>(defaults: T, overrides: Partial<T>): T;
|
|
2
|
+
export declare function omitOptionKeys<T extends object, K extends keyof T>(input: T, keys: readonly K[]): Omit<T, K>;
|
|
3
|
+
export declare function setIfDefined<T extends object, K extends keyof T>(target: T, key: K, value: T[K] | undefined): void;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function mergeOptions(defaults, overrides) {
|
|
2
|
+
return { ...defaults, ...overrides };
|
|
3
|
+
}
|
|
4
|
+
export function omitOptionKeys(input, keys) {
|
|
5
|
+
const output = { ...input };
|
|
6
|
+
for (const key of keys) {
|
|
7
|
+
Reflect.deleteProperty(output, key);
|
|
8
|
+
}
|
|
9
|
+
return output;
|
|
10
|
+
}
|
|
11
|
+
export function setIfDefined(target, key, value) {
|
|
12
|
+
if (value !== undefined) {
|
|
13
|
+
target[key] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -8,6 +8,7 @@ import { McpError } from './errors.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function normalizePath(p: string): string;
|
|
10
10
|
export declare function getAllowedDirectories(): string[];
|
|
11
|
+
export declare function isAllowedDirectoryRoot(normalizedPath: string): boolean;
|
|
11
12
|
export declare function isPathWithinDirectories(normalizedPath: string, allowedDirs: readonly string[]): boolean;
|
|
12
13
|
export declare function setAllowedDirectoriesResolved(dirs: readonly string[], signal?: AbortSignal): Promise<void>;
|
|
13
14
|
export declare function getReservedDeviceNameForPath(requestedPath: string): string | undefined;
|
|
@@ -124,6 +124,13 @@ function setAllowedDirectoriesState(primary, expanded) {
|
|
|
124
124
|
export function getAllowedDirectories() {
|
|
125
125
|
return [...allowedDirectoriesExpanded];
|
|
126
126
|
}
|
|
127
|
+
export function isAllowedDirectoryRoot(normalizedPath) {
|
|
128
|
+
for (const dir of allowedDirectoriesExpanded) {
|
|
129
|
+
if (isSamePath(normalizedPath, dir))
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
127
134
|
function getAllowedDirectoriesForRelativeResolution() {
|
|
128
135
|
return allowedDirectoriesPrimary.length > 0
|
|
129
136
|
? allowedDirectoriesPrimary
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ProgressPayload {
|
|
2
|
+
current: number;
|
|
3
|
+
total?: number;
|
|
4
|
+
}
|
|
5
|
+
export type ProgressCallback = ((progress: ProgressPayload) => void) | undefined;
|
|
6
|
+
export interface PeriodicProgressOptions {
|
|
7
|
+
total?: number;
|
|
8
|
+
throttleModulo?: number;
|
|
9
|
+
force?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function reportPeriodicProgress(onProgress: ProgressCallback, current: number, options?: PeriodicProgressOptions): void;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function reportPeriodicProgress(onProgress, current, options = {}) {
|
|
2
|
+
if (!onProgress || current === 0)
|
|
3
|
+
return;
|
|
4
|
+
const throttleModulo = options.throttleModulo ?? 1;
|
|
5
|
+
const force = options.force ?? false;
|
|
6
|
+
if (!force && throttleModulo > 1 && current % throttleModulo !== 0) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
onProgress({
|
|
10
|
+
current,
|
|
11
|
+
...(options.total !== undefined ? { total: options.total } : {}),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -48,6 +48,9 @@ async function handleApplyPatch(args, signal) {
|
|
|
48
48
|
if (patched === false) {
|
|
49
49
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch application failed. The file content may have changed or patch context is insufficient. Generate a fresh patch via diff_files against the current file, then retry. If differences are minor, enable fuzzy matching with the fuzzFactor parameter.');
|
|
50
50
|
}
|
|
51
|
+
if (patched === content) {
|
|
52
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch had no effect — the file content is unchanged after applying. The patch may not match the current file content. Generate a fresh patch via diff_files and retry.');
|
|
53
|
+
}
|
|
51
54
|
if (args.dryRun) {
|
|
52
55
|
return buildToolResponse('Dry run successful. Patch can be applied.', {
|
|
53
56
|
ok: true,
|
|
@@ -8,8 +8,9 @@ import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations
|
|
|
8
8
|
import { globEntries } from '../lib/file-operations/glob-engine.js';
|
|
9
9
|
import { assertNotAborted, withAbort } from '../lib/fs-helpers.js';
|
|
10
10
|
import { validateExistingPath } from '../lib/path-validation.js';
|
|
11
|
+
import { reportPeriodicProgress } from '../lib/progress-reporting.js';
|
|
11
12
|
import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
|
|
12
|
-
import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
13
|
+
import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
13
14
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
14
15
|
const WINDOWS_PATH_SEPARATOR = /\\/gu;
|
|
15
16
|
export const CALCULATE_HASH_TOOL = {
|
|
@@ -54,13 +55,6 @@ function updateCompositeHash(hasher, pathLengthBytes, relativePath, fileHash) {
|
|
|
54
55
|
hasher.update(relativePathBytes);
|
|
55
56
|
hasher.update(fileHash);
|
|
56
57
|
}
|
|
57
|
-
function reportHashProgress(onProgress, current, force = false) {
|
|
58
|
-
if (!onProgress || current === 0)
|
|
59
|
-
return;
|
|
60
|
-
if (!force && current % 25 !== 0)
|
|
61
|
-
return;
|
|
62
|
-
onProgress({ current });
|
|
63
|
-
}
|
|
64
58
|
async function hashDirectory(dirPath, options = {}) {
|
|
65
59
|
const { signal, onProgress } = options;
|
|
66
60
|
const gitignoreMatcher = await loadRootGitignore(dirPath, signal);
|
|
@@ -102,9 +96,12 @@ async function hashDirectory(dirPath, options = {}) {
|
|
|
102
96
|
}));
|
|
103
97
|
entries.push(...batchResults);
|
|
104
98
|
filesHashed += batchResults.length;
|
|
105
|
-
|
|
99
|
+
reportPeriodicProgress(onProgress, filesHashed, { throttleModulo: 25 });
|
|
106
100
|
}
|
|
107
|
-
|
|
101
|
+
reportPeriodicProgress(onProgress, filesHashed, {
|
|
102
|
+
throttleModulo: 25,
|
|
103
|
+
force: true,
|
|
104
|
+
});
|
|
108
105
|
assertNotAborted(signal);
|
|
109
106
|
// Sort by path with byte-wise semantics for deterministic ordering.
|
|
110
107
|
entries.sort(comparePaths);
|
|
@@ -141,7 +138,10 @@ async function handleCalculateHash(args, signal, onProgress) {
|
|
|
141
138
|
else {
|
|
142
139
|
// Hash single file
|
|
143
140
|
const hash = await hashFile(validPath, 'hex', signal);
|
|
144
|
-
|
|
141
|
+
reportPeriodicProgress(onProgress, 1, {
|
|
142
|
+
throttleModulo: 25,
|
|
143
|
+
force: true,
|
|
144
|
+
});
|
|
145
145
|
return buildToolResponse(hash, {
|
|
146
146
|
ok: true,
|
|
147
147
|
path: validPath,
|
|
@@ -171,7 +171,7 @@ export function registerCalculateHashTool(server, options = {}) {
|
|
|
171
171
|
const result = await handleCalculateHash(args, signal, progressWithMessage);
|
|
172
172
|
const sc = result.structuredContent;
|
|
173
173
|
const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
|
|
174
|
-
const finalCurrent =
|
|
174
|
+
const finalCurrent = resolveFinalProgressCurrent(progress, totalFiles + 1);
|
|
175
175
|
let suffix;
|
|
176
176
|
if (!sc.ok) {
|
|
177
177
|
suffix = 'failed';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { ErrorCode, isNodeError } from '../lib/errors.js';
|
|
3
|
+
import { ErrorCode, isNodeError, McpError } from '../lib/errors.js';
|
|
4
4
|
import { withAbort } from '../lib/fs-helpers.js';
|
|
5
|
-
import { validatePathForWrite } from '../lib/path-validation.js';
|
|
5
|
+
import { isAllowedDirectoryRoot, validatePathForWrite, } from '../lib/path-validation.js';
|
|
6
6
|
import { DeleteFileInputSchema, DeleteFileOutputSchema } from '../schemas.js';
|
|
7
7
|
import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
8
8
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
@@ -20,6 +20,9 @@ export const DELETE_FILE_TOOL = {
|
|
|
20
20
|
};
|
|
21
21
|
async function handleDeleteFile(args, signal) {
|
|
22
22
|
const validPath = await validatePathForWrite(args.path, signal);
|
|
23
|
+
if (isAllowedDirectoryRoot(validPath)) {
|
|
24
|
+
throw new McpError(ErrorCode.E_ACCESS_DENIED, `Deleting a workspace root directory is not allowed: ${args.path}`);
|
|
25
|
+
}
|
|
23
26
|
let stats;
|
|
24
27
|
try {
|
|
25
28
|
stats = await withAbort(fs.lstat(validPath), signal);
|
|
@@ -3,7 +3,7 @@ import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, } from '..
|
|
|
3
3
|
import { ErrorCode } from '../lib/errors.js';
|
|
4
4
|
import { readMultipleFiles } from '../lib/file-operations/read-multiple-files.js';
|
|
5
5
|
import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
|
|
6
|
-
import { buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse,
|
|
6
|
+
import { buildBatchCompletionSuffix, buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
7
7
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
8
8
|
export const READ_MULTIPLE_FILES_TOOL = {
|
|
9
9
|
name: 'read_many',
|
|
@@ -20,23 +20,6 @@ export const READ_MULTIPLE_FILES_TOOL = {
|
|
|
20
20
|
'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
|
|
21
21
|
],
|
|
22
22
|
};
|
|
23
|
-
function buildReadManyCompletionSuffix(summary) {
|
|
24
|
-
const total = summary?.total ?? 0;
|
|
25
|
-
const failed = summary?.failed ?? 0;
|
|
26
|
-
const succeeded = summary?.succeeded ?? 0;
|
|
27
|
-
if (failed) {
|
|
28
|
-
return `${succeeded}/${total} read, ${failed} failed`;
|
|
29
|
-
}
|
|
30
|
-
const label = total === 1 ? 'file' : 'files';
|
|
31
|
-
return `${total} ${label} read`;
|
|
32
|
-
}
|
|
33
|
-
function createReadManyProgressCallbacks(extra, context, totalPaths) {
|
|
34
|
-
const progress = createToolProgressSession(extra, `🕮 read_many: ${context}`);
|
|
35
|
-
const onReadComplete = () => {
|
|
36
|
-
progress.increment((current) => `🕮 read_many: ${context} [${current}/${totalPaths} read]`);
|
|
37
|
-
};
|
|
38
|
-
return { progress, onReadComplete };
|
|
39
|
-
}
|
|
40
23
|
function toStructuredReadManyResult(result) {
|
|
41
24
|
const structured = {
|
|
42
25
|
path: result.path,
|
|
@@ -152,13 +135,18 @@ export function registerReadMultipleFilesTool(server, options = {}) {
|
|
|
152
135
|
context: { path: primaryPath },
|
|
153
136
|
run: async (signal) => {
|
|
154
137
|
const context = buildBatchPathContext(args.paths, 'files');
|
|
155
|
-
const { progress,
|
|
138
|
+
const { progress, onItemComplete } = createBatchProgressCallbacks(extra, {
|
|
139
|
+
toolLabel: '🕮 read_many',
|
|
140
|
+
context,
|
|
141
|
+
totalItems: args.paths.length,
|
|
142
|
+
itemVerb: 'read',
|
|
143
|
+
});
|
|
156
144
|
try {
|
|
157
|
-
const result = await handleReadMultipleFiles(args, signal, options.resourceStore,
|
|
145
|
+
const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onItemComplete);
|
|
158
146
|
const sc = result.structuredContent;
|
|
159
|
-
const suffix =
|
|
147
|
+
const suffix = buildBatchCompletionSuffix(sc.summary, 'files read', 'file read');
|
|
160
148
|
const total = sc.summary?.total ?? 0;
|
|
161
|
-
const finalCurrent =
|
|
149
|
+
const finalCurrent = resolveFinalProgressCurrent(progress, total);
|
|
162
150
|
progress.complete(`🕮 read_many: ${context} • ${suffix}`, finalCurrent);
|
|
163
151
|
return result;
|
|
164
152
|
}
|
|
@@ -8,8 +8,9 @@ import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.j
|
|
|
8
8
|
import { globEntries } from '../lib/file-operations/glob-engine.js';
|
|
9
9
|
import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
|
|
10
10
|
import { validateExistingPath, validatePathForWrite, } from '../lib/path-validation.js';
|
|
11
|
+
import { reportPeriodicProgress } from '../lib/progress-reporting.js';
|
|
11
12
|
import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
|
|
12
|
-
import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
13
|
+
import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
13
14
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
14
15
|
export const SEARCH_AND_REPLACE_TOOL = {
|
|
15
16
|
name: 'search_and_replace',
|
|
@@ -204,13 +205,6 @@ function createReplacementMatcher(args) {
|
|
|
204
205
|
}
|
|
205
206
|
return createLiteralReplacementMatcher(args.searchPattern);
|
|
206
207
|
}
|
|
207
|
-
function reportReplaceProgress(onProgress, current, force = false) {
|
|
208
|
-
if (current === 0)
|
|
209
|
-
return;
|
|
210
|
-
if (!force && current % 25 !== 0)
|
|
211
|
-
return;
|
|
212
|
-
onProgress({ current });
|
|
213
|
-
}
|
|
214
208
|
export async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
|
|
215
209
|
const maxFileSize = MAX_TEXT_FILE_SIZE;
|
|
216
210
|
const root = await resolveSearchRoot(args.path, signal);
|
|
@@ -233,14 +227,19 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
|
|
|
233
227
|
concurrency: REPLACE_CONCURRENCY,
|
|
234
228
|
onEntry: () => {
|
|
235
229
|
summary.processedFiles++;
|
|
236
|
-
|
|
230
|
+
reportPeriodicProgress(onProgress, summary.processedFiles, {
|
|
231
|
+
throttleModulo: 25,
|
|
232
|
+
});
|
|
237
233
|
},
|
|
238
234
|
runEntry: async (entryPath) => processEntry(entryPath, {
|
|
239
235
|
dryRun: args.dryRun,
|
|
240
236
|
returnDiff: args.returnDiff ?? false,
|
|
241
237
|
}, args.replacement, matcher, maxFileSize, signal, summary),
|
|
242
238
|
});
|
|
243
|
-
|
|
239
|
+
reportPeriodicProgress(onProgress, summary.processedFiles, {
|
|
240
|
+
throttleModulo: 25,
|
|
241
|
+
force: true,
|
|
242
|
+
});
|
|
244
243
|
const failureSuffix = summary.failedFiles > 0 ? ` (${summary.failedFiles} failed)` : '';
|
|
245
244
|
return buildToolResponse(`Found ${summary.totalMatches} matches in ${summary.filesChanged} files${failureSuffix}.${args.dryRun ? ' (Dry run)' : ''}`, {
|
|
246
245
|
ok: true,
|
|
@@ -279,7 +278,7 @@ export function registerSearchAndReplaceTool(server, options = {}) {
|
|
|
279
278
|
try {
|
|
280
279
|
const result = await handleSearchAndReplace(args, signal, progressWithMessage);
|
|
281
280
|
const sc = result.structuredContent;
|
|
282
|
-
const finalCurrent =
|
|
281
|
+
const finalCurrent = resolveFinalProgressCurrent(progress, (sc.processedFiles ?? 0) + 1);
|
|
283
282
|
const matchWord = (sc.matches ?? 0) === 1 ? 'match' : 'matches';
|
|
284
283
|
const fileWord = (sc.filesChanged ?? 0) === 1 ? 'file' : 'files';
|
|
285
284
|
let endSuffix = `${sc.matches ?? 0} ${matchWord} in ${sc.filesChanged ?? 0} ${fileWord}`;
|
|
@@ -5,7 +5,7 @@ import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
|
|
|
5
5
|
import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
|
|
6
6
|
import { searchContent } from '../lib/file-operations/search-content.js';
|
|
7
7
|
import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas.js';
|
|
8
|
-
import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
8
|
+
import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
9
9
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
10
10
|
const MAX_INLINE_MATCHES = parseInt(process.env['FS_CONTEXT_MAX_INLINE_MATCHES'] ?? '', 10) || 50;
|
|
11
11
|
export const SEARCH_CONTENT_TOOL = {
|
|
@@ -242,7 +242,7 @@ export function registerSearchContentTool(server, options = {}) {
|
|
|
242
242
|
suffix += ' [truncated — max files]';
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
|
-
const finalCurrent =
|
|
245
|
+
const finalCurrent = resolveFinalProgressCurrent(progress, (sc.filesScanned ?? 0) + 1);
|
|
246
246
|
progress.complete(`🔎︎ grep: ${pattern} • ${suffix}`, finalCurrent);
|
|
247
247
|
return result;
|
|
248
248
|
}
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -123,7 +123,18 @@ export interface ToolProgressSession {
|
|
|
123
123
|
fail: (message: string, minimumCurrent?: number) => void;
|
|
124
124
|
getCurrent: () => number;
|
|
125
125
|
}
|
|
126
|
+
export interface BatchProgressCallbacks {
|
|
127
|
+
progress: ToolProgressSession;
|
|
128
|
+
onItemComplete: () => void;
|
|
129
|
+
}
|
|
126
130
|
export declare function createToolProgressSession(extra: ToolExtra, startMessage: string): ToolProgressSession;
|
|
131
|
+
export declare function createBatchProgressCallbacks(extra: ToolExtra, params: {
|
|
132
|
+
toolLabel: string;
|
|
133
|
+
context: string;
|
|
134
|
+
totalItems: number;
|
|
135
|
+
itemVerb: string;
|
|
136
|
+
}): BatchProgressCallbacks;
|
|
137
|
+
export declare function resolveFinalProgressCurrent(progress: ToolProgressSession, ...candidates: number[]): number;
|
|
127
138
|
export declare function wrapToolHandler<Args, Result>(handler: (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>, options: {
|
|
128
139
|
guard?: (() => boolean) | undefined;
|
|
129
140
|
progressMessage?: (args: Args) => string;
|
|
@@ -142,3 +153,8 @@ export declare function resolvePathOrRoot(pathValue: string | undefined): string
|
|
|
142
153
|
export declare function encodeOffsetCursor(offset: number): string;
|
|
143
154
|
export declare function decodeOffsetCursor(cursor: string): number;
|
|
144
155
|
export declare function buildBatchPathContext(paths: readonly string[], unitLabel?: string): string;
|
|
156
|
+
export declare function buildBatchCompletionSuffix(summary: {
|
|
157
|
+
total?: number;
|
|
158
|
+
failed?: number;
|
|
159
|
+
succeeded?: number;
|
|
160
|
+
} | undefined, successWord: string, singularWord?: string): string;
|
package/dist/tools/shared.js
CHANGED
|
@@ -293,6 +293,15 @@ export function createToolProgressSession(extra, startMessage) {
|
|
|
293
293
|
cursor = value;
|
|
294
294
|
return cursor;
|
|
295
295
|
};
|
|
296
|
+
const finishProgress = (message, minimumCurrent) => {
|
|
297
|
+
const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
|
|
298
|
+
notifyProgress(extra, {
|
|
299
|
+
current: finalCurrent,
|
|
300
|
+
total: finalCurrent,
|
|
301
|
+
...(message !== undefined ? { message } : {}),
|
|
302
|
+
});
|
|
303
|
+
cursor = finalCurrent;
|
|
304
|
+
};
|
|
296
305
|
return {
|
|
297
306
|
update: ({ current, total, message }) => {
|
|
298
307
|
const normalized = setCursor(current);
|
|
@@ -309,27 +318,27 @@ export function createToolProgressSession(extra, startMessage) {
|
|
|
309
318
|
message: messageForCurrent(next),
|
|
310
319
|
});
|
|
311
320
|
},
|
|
312
|
-
complete:
|
|
313
|
-
|
|
314
|
-
notifyProgress(extra, {
|
|
315
|
-
current: finalCurrent,
|
|
316
|
-
total: finalCurrent,
|
|
317
|
-
message,
|
|
318
|
-
});
|
|
319
|
-
cursor = finalCurrent;
|
|
320
|
-
},
|
|
321
|
-
fail: (message, minimumCurrent) => {
|
|
322
|
-
const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
|
|
323
|
-
notifyProgress(extra, {
|
|
324
|
-
current: finalCurrent,
|
|
325
|
-
total: finalCurrent,
|
|
326
|
-
message,
|
|
327
|
-
});
|
|
328
|
-
cursor = finalCurrent;
|
|
329
|
-
},
|
|
321
|
+
complete: finishProgress,
|
|
322
|
+
fail: finishProgress,
|
|
330
323
|
getCurrent: () => cursor,
|
|
331
324
|
};
|
|
332
325
|
}
|
|
326
|
+
export function createBatchProgressCallbacks(extra, params) {
|
|
327
|
+
const progress = createToolProgressSession(extra, `${params.toolLabel}: ${params.context}`);
|
|
328
|
+
const onItemComplete = () => {
|
|
329
|
+
progress.increment((current) => `${params.toolLabel}: ${params.context} [${current}/${params.totalItems} ${params.itemVerb}]`);
|
|
330
|
+
};
|
|
331
|
+
return { progress, onItemComplete };
|
|
332
|
+
}
|
|
333
|
+
export function resolveFinalProgressCurrent(progress, ...candidates) {
|
|
334
|
+
let finalCurrent = progress.getCurrent() + 1;
|
|
335
|
+
for (const candidate of candidates) {
|
|
336
|
+
if (candidate > finalCurrent) {
|
|
337
|
+
finalCurrent = candidate;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return finalCurrent;
|
|
341
|
+
}
|
|
333
342
|
async function withProgress(message, extra, run, getCompletionMessage) {
|
|
334
343
|
if (!canReportProgress(extra)) {
|
|
335
344
|
return run();
|
|
@@ -430,3 +439,13 @@ export function buildBatchPathContext(paths, unitLabel = 'paths') {
|
|
|
430
439
|
: '';
|
|
431
440
|
return `${paths.length} ${normalizedLabel} [${first}${extraPaths}]`;
|
|
432
441
|
}
|
|
442
|
+
export function buildBatchCompletionSuffix(summary, successWord, singularWord) {
|
|
443
|
+
const total = summary?.total ?? 0;
|
|
444
|
+
const failed = summary?.failed ?? 0;
|
|
445
|
+
const succeeded = summary?.succeeded ?? 0;
|
|
446
|
+
if (failed) {
|
|
447
|
+
return `${succeeded}/${total} ${successWord}, ${failed} failed`;
|
|
448
|
+
}
|
|
449
|
+
const word = total === 1 && singularWord ? singularWord : successWord;
|
|
450
|
+
return `${total} ${word}`;
|
|
451
|
+
}
|
package/dist/tools/stat-many.js
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
|
|
|
3
3
|
import { ErrorCode } from '../lib/errors.js';
|
|
4
4
|
import { getMultipleFileInfo } from '../lib/file-operations/file-info.js';
|
|
5
5
|
import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
|
|
6
|
-
import { buildBatchPathContext, buildFileInfoPayload, buildToolErrorResponse, buildToolResponse,
|
|
6
|
+
import { buildBatchCompletionSuffix, buildBatchPathContext, buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
7
7
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
8
8
|
export const GET_MULTIPLE_FILE_INFO_TOOL = {
|
|
9
9
|
name: 'stat_many',
|
|
@@ -15,22 +15,6 @@ export const GET_MULTIPLE_FILE_INFO_TOOL = {
|
|
|
15
15
|
taskSupport: 'optional',
|
|
16
16
|
nuances: ['Use before read/search when file size/type uncertainty exists.'],
|
|
17
17
|
};
|
|
18
|
-
function buildStatManyCompletionSuffix(summary) {
|
|
19
|
-
const total = summary?.total ?? 0;
|
|
20
|
-
const failed = summary?.failed ?? 0;
|
|
21
|
-
const succeeded = summary?.succeeded ?? 0;
|
|
22
|
-
if (failed) {
|
|
23
|
-
return `${succeeded}/${total} OK, ${failed} failed`;
|
|
24
|
-
}
|
|
25
|
-
return `${total} OK`;
|
|
26
|
-
}
|
|
27
|
-
function createStatManyProgressCallbacks(extra, context, totalPaths) {
|
|
28
|
-
const progress = createToolProgressSession(extra, `🕮 stat_many: ${context}`);
|
|
29
|
-
const onProgress = () => {
|
|
30
|
-
progress.increment((current) => `🕮 stat_many: ${context} [${current}/${totalPaths} scanned]`);
|
|
31
|
-
};
|
|
32
|
-
return { progress, onProgress };
|
|
33
|
-
}
|
|
34
18
|
function formatFileInfoDetail(info) {
|
|
35
19
|
const lines = [
|
|
36
20
|
`${info.name} (${info.type})`,
|
|
@@ -90,13 +74,18 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
|
|
|
90
74
|
context: { path: primaryPath },
|
|
91
75
|
run: async (signal) => {
|
|
92
76
|
const context = buildBatchPathContext(args.paths);
|
|
93
|
-
const { progress,
|
|
77
|
+
const { progress, onItemComplete } = createBatchProgressCallbacks(extra, {
|
|
78
|
+
toolLabel: '🕮 stat_many',
|
|
79
|
+
context,
|
|
80
|
+
totalItems: args.paths.length,
|
|
81
|
+
itemVerb: 'scanned',
|
|
82
|
+
});
|
|
94
83
|
try {
|
|
95
|
-
const result = await handleGetMultipleFileInfo(args, signal,
|
|
84
|
+
const result = await handleGetMultipleFileInfo(args, signal, onItemComplete);
|
|
96
85
|
const sc = result.structuredContent;
|
|
97
|
-
const suffix =
|
|
86
|
+
const suffix = buildBatchCompletionSuffix(sc.summary, 'OK');
|
|
98
87
|
const total = sc.summary?.total ?? 0;
|
|
99
|
-
const finalCurrent =
|
|
88
|
+
const finalCurrent = resolveFinalProgressCurrent(progress, total);
|
|
100
89
|
progress.complete(`🕮 stat_many: ${context} • ${suffix}`, finalCurrent);
|
|
101
90
|
return result;
|
|
102
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@j0hanz/filesystem-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"mcpName": "io.github.j0hanz/filesystem-mcp",
|
|
5
5
|
"description": "MCP Server that enables LLMs to interact with the local filesystem.",
|
|
6
6
|
"type": "module",
|
|
@@ -35,8 +35,11 @@
|
|
|
35
35
|
"type-check:diagnostics": "tsc --noEmit --extendedDiagnostics",
|
|
36
36
|
"type-check:trace": "node -e \"require('fs').rmSync('.ts-trace',{recursive:true,force:true})\" && tsc --noEmit --generateTrace .ts-trace",
|
|
37
37
|
"lint": "eslint .",
|
|
38
|
+
"lint:tests": "eslint src/__tests__",
|
|
38
39
|
"lint:fix": "eslint . --fix",
|
|
39
40
|
"test": "node scripts/tasks.mjs test",
|
|
41
|
+
"test:fast": "node --test --import tsx/esm src/__tests__/**/*.test.ts node-tests/**/*.test.ts",
|
|
42
|
+
"test:coverage": "node scripts/tasks.mjs test --coverage",
|
|
40
43
|
"knip": "knip",
|
|
41
44
|
"knip:fix": "knip --fix",
|
|
42
45
|
"inspector": "npm run build && npx -y @modelcontextprotocol/inspector node dist/index.js ${workspaceFolder}",
|
|
@@ -71,12 +74,13 @@
|
|
|
71
74
|
},
|
|
72
75
|
"devDependencies": {
|
|
73
76
|
"@eslint/js": "^10.0.1",
|
|
74
|
-
"eslint": "^10.0.2",
|
|
75
77
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
|
76
78
|
"@types/node": "^24",
|
|
79
|
+
"eslint": "^10.0.2",
|
|
77
80
|
"eslint-config-prettier": "^10.1.8",
|
|
78
81
|
"eslint-plugin-de-morgan": "^2.0.0",
|
|
79
82
|
"eslint-plugin-depend": "^1.4.0",
|
|
83
|
+
"eslint-plugin-sonarjs": "^4.0.0",
|
|
80
84
|
"eslint-plugin-unused-imports": "^4.4.1",
|
|
81
85
|
"jscpd": "^4.0.8",
|
|
82
86
|
"knip": "^5.85.0",
|