@j0hanz/filesystem-mcp 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,7 +90,7 @@ docker run -i --rm \
90
90
 
91
91
  ## Configuration
92
92
 
93
- The server requires specifying allowed directories via command-line arguments.
93
+ Allowed directories can be provided via command-line arguments, via the MCP Roots protocol, or by using `--allow-cwd`.
94
94
 
95
95
  ### Arguments
96
96
 
package/dist/cli.d.ts CHANGED
@@ -2,8 +2,7 @@ export declare class CliExitError extends Error {
2
2
  readonly exitCode: number;
3
3
  constructor(message: string, exitCode: number);
4
4
  }
5
- export interface ParseArgsResult {
5
+ export declare function parseArgs(): Promise<{
6
6
  allowedDirs: string[];
7
7
  allowCwd: boolean;
8
- }
9
- export declare function parseArgs(): Promise<ParseArgsResult>;
8
+ }>;
@@ -4,6 +4,10 @@ interface CompletionResult {
4
4
  total?: number;
5
5
  hasMore?: boolean;
6
6
  }
7
- export declare function getPathCompletions(currentValue: string): Promise<CompletionResult>;
7
+ interface CompletionOptions {
8
+ argumentName?: string;
9
+ contextArguments?: Record<string, string>;
10
+ }
11
+ export declare function getPathCompletions(currentValue: string, options?: CompletionOptions): Promise<CompletionResult>;
8
12
  export declare function registerCompletions(server: McpServer): void;
9
13
  export {};
@@ -3,6 +3,103 @@ import * as path from 'node:path';
3
3
  import { CompleteRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { getAllowedDirectories, isPathWithinDirectories, normalizePath, } from './lib/path-validation.js';
5
5
  const MAX_COMPLETION_ITEMS = 100;
6
+ const PATH_ARGUMENTS = new Set([
7
+ 'path',
8
+ 'source',
9
+ 'destination',
10
+ 'original',
11
+ 'modified',
12
+ 'directory',
13
+ 'file',
14
+ 'root',
15
+ 'cwd',
16
+ ]);
17
+ function isRecord(value) {
18
+ return typeof value === 'object' && value !== null;
19
+ }
20
+ function isPathLikeArgumentName(argName) {
21
+ return (PATH_ARGUMENTS.has(argName) ||
22
+ argName.endsWith('paths') ||
23
+ argName.endsWith('path') ||
24
+ argName.endsWith('files') ||
25
+ argName.endsWith('file') ||
26
+ argName.endsWith('dirs') ||
27
+ argName.endsWith('dir'));
28
+ }
29
+ function parseResourceReference(value) {
30
+ if (!isRecord(value))
31
+ return undefined;
32
+ if (value['type'] !== 'ref/resource')
33
+ return undefined;
34
+ const { uri } = value;
35
+ if (typeof uri !== 'string')
36
+ return undefined;
37
+ return { type: 'ref/resource', uri };
38
+ }
39
+ function extractTemplateVariables(uri) {
40
+ const vars = [];
41
+ const isVariableChar = (char) => {
42
+ const code = char.charCodeAt(0);
43
+ const isDigit = code >= 48 && code <= 57;
44
+ const isUpper = code >= 65 && code <= 90;
45
+ const isLower = code >= 97 && code <= 122;
46
+ return isDigit || isUpper || isLower || code === 95;
47
+ };
48
+ let index = 0;
49
+ while (index < uri.length) {
50
+ const start = uri.indexOf('{', index);
51
+ if (start === -1)
52
+ break;
53
+ const end = uri.indexOf('}', start + 1);
54
+ if (end === -1)
55
+ break;
56
+ const raw = uri.slice(start + 1, end);
57
+ let normalized = '';
58
+ for (const char of raw) {
59
+ if (isVariableChar(char)) {
60
+ normalized += char.toLowerCase();
61
+ }
62
+ }
63
+ if (normalized.length > 0)
64
+ vars.push(normalized);
65
+ index = end + 1;
66
+ }
67
+ return vars;
68
+ }
69
+ function isPathArgumentFromReference(argumentName, ref) {
70
+ const resourceRef = parseResourceReference(ref);
71
+ if (!resourceRef)
72
+ return false;
73
+ const normalizedArg = argumentName.toLowerCase();
74
+ const templateVars = extractTemplateVariables(resourceRef.uri);
75
+ if (templateVars.length === 0)
76
+ return false;
77
+ const matchesVariable = templateVars.includes(normalizedArg);
78
+ if (!matchesVariable)
79
+ return false;
80
+ if (isPathLikeArgumentName(normalizedArg))
81
+ return true;
82
+ if (resourceRef.uri.toLowerCase().includes('file:///'))
83
+ return true;
84
+ const uriLooksPathLike = resourceRef.uri.includes('/') &&
85
+ (resourceRef.uri.toLowerCase().includes('path') ||
86
+ resourceRef.uri.toLowerCase().includes('file') ||
87
+ resourceRef.uri.toLowerCase().includes('dir') ||
88
+ resourceRef.uri.toLowerCase().includes('root') ||
89
+ resourceRef.uri.toLowerCase().includes('cwd'));
90
+ return uriLooksPathLike;
91
+ }
92
+ function extractContextArguments(value) {
93
+ if (!isRecord(value))
94
+ return undefined;
95
+ const context = value['arguments'];
96
+ if (!isRecord(context))
97
+ return undefined;
98
+ const entries = Object.entries(context).filter((entry) => typeof entry[1] === 'string');
99
+ if (entries.length === 0)
100
+ return undefined;
101
+ return Object.fromEntries(entries.map(([key, val]) => [key.toLowerCase(), val]));
102
+ }
6
103
  function hasTrailingSeparator(value) {
7
104
  return (value.endsWith(path.sep) || value.endsWith('/') || value.endsWith('\\'));
8
105
  }
@@ -33,18 +130,97 @@ function resolveNamedRootContext(currentValue, allowed) {
33
130
  const remainder = rest.join(path.sep);
34
131
  return resolveFromBase(root, remainder, trailingSeparator);
35
132
  }
36
- function getSearchContext(currentValue, allowed) {
133
+ function resolveNamedRootPath(value, allowed) {
134
+ const normalizedInput = value.replace(/\\/gu, '/');
135
+ const [rootName, ...rest] = normalizedInput.split('/');
136
+ if (!rootName)
137
+ return undefined;
138
+ const root = allowed.find((candidate) => path.basename(candidate).toLowerCase() === rootName.toLowerCase());
139
+ if (!root)
140
+ return undefined;
141
+ const remainder = rest.join(path.sep);
142
+ return normalizePath(path.resolve(root, remainder));
143
+ }
144
+ function chooseContextKeys(argumentName) {
145
+ const normalized = argumentName.toLowerCase();
146
+ if (normalized === 'destination') {
147
+ return ['source', 'path', 'cwd', 'root'];
148
+ }
149
+ if (normalized === 'path' ||
150
+ normalized === 'source' ||
151
+ normalized === 'original' ||
152
+ normalized === 'modified' ||
153
+ normalized === 'file') {
154
+ return ['path', 'cwd', 'root'];
155
+ }
156
+ return ['path', 'source', 'cwd', 'root'];
157
+ }
158
+ function resolveContextCandidatePath(candidate, allowed) {
159
+ if (isAbsolutePathInput(candidate)) {
160
+ return normalizePath(candidate);
161
+ }
162
+ if (allowed.length === 1) {
163
+ const base = allowed[0];
164
+ if (!base)
165
+ return undefined;
166
+ return normalizePath(path.resolve(base, candidate));
167
+ }
168
+ return resolveNamedRootPath(candidate, allowed);
169
+ }
170
+ async function toAllowedContextDirectory(resolved, allowed) {
171
+ if (!isPathWithinDirectories(resolved, allowed))
172
+ return undefined;
173
+ try {
174
+ const stats = await fs.stat(resolved);
175
+ if (stats.isDirectory())
176
+ return resolved;
177
+ }
178
+ catch {
179
+ // Fall back to parent path best-effort resolution.
180
+ }
181
+ const parent = path.dirname(resolved);
182
+ return isPathWithinDirectories(parent, allowed) ? parent : undefined;
183
+ }
184
+ async function resolveContextBaseDirectory(argumentName, contextArguments, allowed) {
185
+ if (!contextArguments || Object.keys(contextArguments).length === 0) {
186
+ return undefined;
187
+ }
188
+ const keys = chooseContextKeys(argumentName);
189
+ for (const key of keys) {
190
+ const candidate = contextArguments[key];
191
+ if (!candidate || candidate.trim().length === 0)
192
+ continue;
193
+ const resolved = resolveContextCandidatePath(candidate, allowed);
194
+ if (!resolved)
195
+ continue;
196
+ const baseDirectory = await toAllowedContextDirectory(resolved, allowed);
197
+ if (baseDirectory)
198
+ return baseDirectory;
199
+ }
200
+ return undefined;
201
+ }
202
+ function getSearchContext(currentValue, allowed, contextBase) {
37
203
  const trailingSeparator = hasTrailingSeparator(currentValue);
38
204
  if (isAbsolutePathInput(currentValue)) {
39
205
  return resolveFromBase(path.parse(currentValue).root || path.sep, currentValue, trailingSeparator);
40
206
  }
207
+ const namedRootContext = resolveNamedRootContext(currentValue, allowed);
208
+ if (namedRootContext) {
209
+ return namedRootContext;
210
+ }
211
+ if (contextBase) {
212
+ if (currentValue.length === 0) {
213
+ return { searchDir: contextBase, prefix: '' };
214
+ }
215
+ return resolveFromBase(contextBase, currentValue, trailingSeparator);
216
+ }
41
217
  if (allowed.length === 1) {
42
218
  const base = allowed[0];
43
219
  if (base) {
44
220
  return resolveFromBase(base, currentValue, trailingSeparator);
45
221
  }
46
222
  }
47
- return resolveNamedRootContext(currentValue, allowed);
223
+ return undefined;
48
224
  }
49
225
  async function findMatchesInDirectory(searchDir, prefix, allowed) {
50
226
  const matches = [];
@@ -92,18 +268,19 @@ function findMatchingRoots(searchDir, prefix, allowed) {
92
268
  }
93
269
  return matches;
94
270
  }
95
- export async function getPathCompletions(currentValue) {
271
+ export async function getPathCompletions(currentValue, options = {}) {
96
272
  const allowed = getAllowedDirectories();
97
- // If empty, suggest allowed roots
98
- if (!currentValue) {
99
- return {
100
- values: allowed,
101
- total: allowed.length,
102
- hasMore: false,
103
- };
104
- }
105
273
  try {
106
- const context = getSearchContext(currentValue, allowed);
274
+ const contextBase = await resolveContextBaseDirectory(options.argumentName ?? '', options.contextArguments, allowed);
275
+ // If no value and no context base, suggest roots.
276
+ if (!currentValue && !contextBase) {
277
+ return {
278
+ values: allowed,
279
+ total: allowed.length,
280
+ hasMore: false,
281
+ };
282
+ }
283
+ const context = getSearchContext(currentValue, allowed, contextBase);
107
284
  if (!context) {
108
285
  const rootMatches = findRootPrefixMatches(currentValue, allowed);
109
286
  const sliced = rootMatches.slice(0, MAX_COMPLETION_ITEMS);
@@ -143,32 +320,19 @@ export async function getPathCompletions(currentValue) {
143
320
  export function registerCompletions(server) {
144
321
  server.server.setRequestHandler(CompleteRequestSchema, async (request) => {
145
322
  const { params } = request;
146
- const { argument } = params;
147
- const pathArguments = new Set([
148
- 'path',
149
- 'source',
150
- 'destination',
151
- 'original',
152
- 'modified',
153
- 'directory',
154
- 'file',
155
- 'root',
156
- 'cwd',
157
- ]);
158
- // Check if argument name is relevant or ends with path-like suffixes
323
+ const { argument, ref } = params;
159
324
  const argName = argument.name.toLowerCase();
160
- const isPathArg = pathArguments.has(argName) ||
161
- argName.endsWith('paths') ||
162
- argName.endsWith('path') ||
163
- argName.endsWith('files') ||
164
- argName.endsWith('file') ||
165
- argName.endsWith('dirs') ||
166
- argName.endsWith('dir');
325
+ const isPathArg = isPathLikeArgumentName(argName) ||
326
+ isPathArgumentFromReference(argName, ref);
167
327
  if (!isPathArg) {
168
328
  return { completion: { values: [], total: 0, hasMore: false } };
169
329
  }
330
+ const contextArguments = extractContextArguments(params.context);
170
331
  const { value } = argument;
171
- const completions = await getPathCompletions(value);
332
+ const completions = await getPathCompletions(value, {
333
+ argumentName: argName,
334
+ ...(contextArguments ? { contextArguments } : {}),
335
+ });
172
336
  return {
173
337
  completion: {
174
338
  values: completions.values,
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ async function shutdown(reason, exitCode = 0) {
19
19
  console.error(`Shutdown timed out (${reason}), forcing exit.`);
20
20
  process.exit(exitCode);
21
21
  }, SHUTDOWN_TIMEOUT_MS);
22
+ timer.unref();
22
23
  try {
23
24
  if (activeServer) {
24
25
  await activeServer.close();
@@ -29,7 +29,7 @@ These instructions are available as a resource (internal://instructions) or prom
29
29
  ## PROGRESS & TASKS
30
30
 
31
31
  - Include `_meta.progressToken` in requests to receive `notifications/progress` updates for long-running tools.
32
- - Task-augmented tool calls are supported for `grep`, `find`, `search_and_replace`, `tree`, `read_many`, and `stat_many`:
32
+ - Task-augmented tool calls are supported for `grep`, `find`, `calculate_hash`, `search_and_replace`, `tree`, `read_many`, and `stat_many`:
33
33
  - These tools declare `execution.taskSupport: "optional"` — invoke normally or as a task.
34
34
  - Send `tools/call` with `task` to get a task id.
35
35
  - Poll `tasks/get` and fetch results via `tasks/result`.
@@ -33,9 +33,30 @@ function parseEnvList(envVar) {
33
33
  .filter((entry) => entry.length > 0);
34
34
  }
35
35
  // Auto-tuned parallelism based on CPU cores (no env override)
36
+ const BYTES_PER_PARALLEL_TASK = 64 * 1024 * 1024;
37
+ const BYTES_PER_SEARCH_WORKER = 128 * 1024 * 1024;
38
+ function getAvailableMemory() {
39
+ if (typeof process.availableMemory !== 'function')
40
+ return undefined;
41
+ const available = process.availableMemory();
42
+ if (!Number.isFinite(available) || available <= 0)
43
+ return undefined;
44
+ return available;
45
+ }
46
+ function applyMemoryBound(cpuBound, bytesPerUnit, minValue) {
47
+ const availableMemory = getAvailableMemory();
48
+ if (availableMemory === undefined)
49
+ return cpuBound;
50
+ const memoryBound = Math.floor(availableMemory / bytesPerUnit);
51
+ return Math.min(cpuBound, Math.max(memoryBound, minValue));
52
+ }
36
53
  function getOptimalParallelism() {
37
- const cpuCores = availableParallelism();
38
- return Math.min(Math.max(cpuCores, 4), 32);
54
+ const cpuBound = Math.min(Math.max(availableParallelism(), 4), 32);
55
+ return applyMemoryBound(cpuBound, BYTES_PER_PARALLEL_TASK, 2);
56
+ }
57
+ function getDefaultSearchWorkers() {
58
+ const cpuBound = Math.min(availableParallelism(), 8);
59
+ return applyMemoryBound(cpuBound, BYTES_PER_SEARCH_WORKER, 1);
39
60
  }
40
61
  // Hardcoded optimal values (no env override needed)
41
62
  export const PARALLEL_CONCURRENCY = getOptimalParallelism();
@@ -52,7 +73,7 @@ const ENV_ALLOWLIST = parseEnvList('FS_CONTEXT_ALLOWLIST');
52
73
  * Default: CPU cores (capped at 8 for optimal I/O performance).
53
74
  * Configurable via FS_CONTEXT_SEARCH_WORKERS env var.
54
75
  */
55
- export const SEARCH_WORKERS = parseEnvInt('FS_CONTEXT_SEARCH_WORKERS', Math.min(availableParallelism(), 8), 0, 16);
76
+ export const SEARCH_WORKERS = parseEnvInt('FS_CONTEXT_SEARCH_WORKERS', getDefaultSearchWorkers(), 0, 16);
56
77
  // Hardcoded defaults
57
78
  export const DEFAULT_MAX_DEPTH = 10;
58
79
  export const DEFAULT_LIST_MAX_ENTRIES = 10000;
@@ -1,9 +1,16 @@
1
1
  import { constants as osConstants } from 'node:os';
2
- import { getSystemErrorName, inspect } from 'node:util';
2
+ import { getSystemErrorMap, getSystemErrorName, inspect } from 'node:util';
3
3
  import { ErrorCode, joinLines } from '../config.js';
4
4
  export { ErrorCode };
5
+ function isNativeError(error) {
6
+ const candidate = Error;
7
+ if (typeof candidate.isError === 'function') {
8
+ return candidate.isError(error);
9
+ }
10
+ return error instanceof Error;
11
+ }
5
12
  export function isNodeError(error) {
6
- if (!(error instanceof Error))
13
+ if (!isNativeError(error))
7
14
  return false;
8
15
  if (!('code' in error))
9
16
  return false;
@@ -11,7 +18,7 @@ export function isNodeError(error) {
11
18
  return typeof code === 'string';
12
19
  }
13
20
  function getNodeErrno(error) {
14
- if (!(error instanceof Error))
21
+ if (!isNativeError(error))
15
22
  return undefined;
16
23
  if (!('errno' in error))
17
24
  return undefined;
@@ -21,6 +28,7 @@ function getNodeErrno(error) {
21
28
  return errno;
22
29
  }
23
30
  const ERRNO_CODE_BY_VALUE = new Map();
31
+ const SYSTEM_ERROR_MAP = getSystemErrorMap();
24
32
  for (const [name, value] of Object.entries(osConstants.errno)) {
25
33
  if (typeof value !== 'number')
26
34
  continue;
@@ -29,6 +37,15 @@ for (const [name, value] of Object.entries(osConstants.errno)) {
29
37
  }
30
38
  }
31
39
  const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]+$/u;
40
+ function getSystemErrorNameFromMap(errno) {
41
+ const direct = SYSTEM_ERROR_MAP.get(errno);
42
+ if (direct)
43
+ return direct[0];
44
+ const normalized = SYSTEM_ERROR_MAP.get(-Math.abs(errno));
45
+ if (normalized)
46
+ return normalized[0];
47
+ return undefined;
48
+ }
32
49
  function getNodeErrorCodeFromErrno(errno) {
33
50
  const direct = ERRNO_CODE_BY_VALUE.get(errno);
34
51
  if (direct)
@@ -36,8 +53,11 @@ function getNodeErrorCodeFromErrno(errno) {
36
53
  const normalized = ERRNO_CODE_BY_VALUE.get(Math.abs(errno));
37
54
  if (normalized)
38
55
  return normalized;
56
+ const fromMap = getSystemErrorNameFromMap(errno);
57
+ if (fromMap && ERROR_CODE_RE.test(fromMap))
58
+ return fromMap;
39
59
  try {
40
- const fromSystem = getSystemErrorName(errno);
60
+ const fromSystem = getSystemErrorName(errno <= 0 ? errno : -errno);
41
61
  return ERROR_CODE_RE.test(fromSystem) ? fromSystem : undefined;
42
62
  }
43
63
  catch {
@@ -55,7 +75,7 @@ function getNodeErrorCodeLabel(error) {
55
75
  export function formatUnknownErrorMessage(error) {
56
76
  if (typeof error === 'string')
57
77
  return error;
58
- if (error instanceof Error)
78
+ if (isNativeError(error))
59
79
  return error.message;
60
80
  try {
61
81
  return inspect(error, {
@@ -99,7 +119,7 @@ function walkErrorChain(error, visitor) {
99
119
  while (current !== undefined && current !== null && !visited.has(current)) {
100
120
  if (visitor(current))
101
121
  return true;
102
- if (!(current instanceof Error))
122
+ if (!isNativeError(current))
103
123
  break;
104
124
  visited.add(current);
105
125
  const next = current.cause;
@@ -108,7 +128,7 @@ function walkErrorChain(error, visitor) {
108
128
  return false;
109
129
  }
110
130
  function isAbortErrorSingle(error) {
111
- if (!(error instanceof Error))
131
+ if (!isNativeError(error))
112
132
  return false;
113
133
  if (error.name === 'AbortError')
114
134
  return true;
@@ -119,7 +139,7 @@ export function isAbortError(error) {
119
139
  return walkErrorChain(error, isAbortErrorSingle);
120
140
  }
121
141
  function isTimeoutErrorSingle(error) {
122
- if (!(error instanceof Error))
142
+ if (!isNativeError(error))
123
143
  return false;
124
144
  if (error.name === 'TimeoutError')
125
145
  return true;
@@ -178,7 +198,7 @@ function getDirectErrorCode(error) {
178
198
  return undefined;
179
199
  }
180
200
  function classifyMessageError(error) {
181
- const message = error instanceof Error ? error.message : String(error);
201
+ const message = isNativeError(error) ? error.message : String(error);
182
202
  const lower = message.toLowerCase();
183
203
  if (lower.includes('enoent') || lower.includes('no such file or directory')) {
184
204
  return ErrorCode.E_NOT_FOUND;
@@ -5,12 +5,6 @@ interface OpsTraceContext {
5
5
  path?: string | undefined;
6
6
  [key: string]: unknown;
7
7
  }
8
- export interface ToolMetrics {
9
- calls: number;
10
- errors: number;
11
- totalDurationMs: number;
12
- }
13
- export declare function getToolMetrics(): Record<string, ToolMetrics>;
14
8
  export declare function shouldPublishOpsTrace(): boolean;
15
9
  export declare function publishOpsTraceStart(context: OpsTraceContext): void;
16
10
  export declare function publishOpsTraceEnd(context: OpsTraceContext): void;
@@ -23,9 +23,6 @@ function parseDetail(val) {
23
23
  return 0;
24
24
  }
25
25
  const globalMetrics = new Map();
26
- export function getToolMetrics() {
27
- return Object.fromEntries(globalMetrics);
28
- }
29
26
  function updateMetrics(tool, ok, durationMs) {
30
27
  const current = globalMetrics.get(tool) ?? {
31
28
  calls: 0,
@@ -146,11 +143,23 @@ function getDelayStats(h) {
146
143
  exceeds: h.exceeds,
147
144
  };
148
145
  }
146
+ function clearPublishedMeasures(entries) {
147
+ if (entries.length === 0)
148
+ return;
149
+ const names = new Set();
150
+ for (const entry of entries) {
151
+ names.add(entry.name);
152
+ }
153
+ for (const name of names) {
154
+ performance.clearMeasures(name);
155
+ }
156
+ }
149
157
  function ensureObserver() {
150
158
  if (perfObserver)
151
159
  return;
152
160
  perfObserver = new PerformanceObserver((list) => {
153
- for (const entry of list.getEntries()) {
161
+ const entries = list.getEntries();
162
+ for (const entry of entries) {
154
163
  CHANNELS.perf.publish({
155
164
  phase: 'measure',
156
165
  name: entry.name,
@@ -158,6 +167,13 @@ function ensureObserver() {
158
167
  detail: entry.detail,
159
168
  });
160
169
  }
170
+ try {
171
+ // Keep the global timeline bounded while preserving published events.
172
+ clearPublishedMeasures(entries);
173
+ }
174
+ catch {
175
+ // Never allow observability cleanup to affect tool execution.
176
+ }
161
177
  });
162
178
  perfObserver.observe({ entryTypes: ['measure'] });
163
179
  }
@@ -208,22 +224,36 @@ export function startPerfMeasure(name, detail) {
208
224
  const startMark = `${name}:start:${id}`;
209
225
  const endMark = `${name}:end:${id}`;
210
226
  const runInCapturedContext = AsyncLocalStorage.snapshot();
227
+ let finished = false;
211
228
  performance.mark(startMark);
212
229
  return (ok) => {
213
- runInCapturedContext(() => {
214
- performance.mark(endMark);
215
- let meta = enrichWithToolContext(detail);
216
- if (ok !== undefined) {
217
- meta = { ...(meta ?? {}), ok };
218
- }
219
- performance.measure(name, {
220
- start: startMark,
221
- end: endMark,
222
- detail: meta,
230
+ if (finished)
231
+ return;
232
+ finished = true;
233
+ try {
234
+ runInCapturedContext(() => {
235
+ try {
236
+ performance.mark(endMark);
237
+ let meta = enrichWithToolContext(detail);
238
+ if (ok !== undefined) {
239
+ meta = { ...(meta ?? {}), ok };
240
+ }
241
+ performance.measure(name, {
242
+ start: startMark,
243
+ end: endMark,
244
+ detail: meta,
245
+ });
246
+ }
247
+ finally {
248
+ performance.clearMarks(startMark);
249
+ performance.clearMarks(endMark);
250
+ }
223
251
  });
252
+ }
253
+ catch {
224
254
  performance.clearMarks(startMark);
225
255
  performance.clearMarks(endMark);
226
- });
256
+ }
227
257
  };
228
258
  }
229
259
  function publishToolStart(tool, pathVal) {
package/dist/schemas.d.ts CHANGED
@@ -11,14 +11,6 @@ interface TreeEntry {
11
11
  relativePath: string;
12
12
  children?: TreeEntry[] | undefined;
13
13
  }
14
- export declare const ErrorSchema: z.ZodObject<{
15
- code: z.ZodEnum<{
16
- [x: string]: string;
17
- }>;
18
- message: z.ZodString;
19
- path: z.ZodOptional<z.ZodString>;
20
- suggestion: z.ZodOptional<z.ZodString>;
21
- }, z.core.$strict>;
22
14
  export declare const ToolErrorResponseSchema: z.ZodObject<{
23
15
  ok: z.ZodLiteral<false>;
24
16
  error: z.ZodObject<{
package/dist/schemas.js CHANGED
@@ -43,7 +43,7 @@ const TreeEntrySchema = z.lazy(() => z.strictObject({
43
43
  relativePath: z.string().describe('Relative path'),
44
44
  children: z.array(TreeEntrySchema).optional().describe('Children'),
45
45
  }));
46
- export const ErrorSchema = z.strictObject({
46
+ const ErrorSchema = z.strictObject({
47
47
  code: z
48
48
  .enum(Object.values(ErrorCode))
49
49
  .describe('Error code (e.g. E_NOT_FOUND)'),
@@ -3,12 +3,14 @@ import * as path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { createReadStream } from 'node:fs';
5
5
  import { ErrorCode } from '../lib/errors.js';
6
+ import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/gitignore.js';
6
7
  import { globEntries } from '../lib/file-operations/glob-engine.js';
7
8
  import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
8
9
  import { withToolDiagnostics } from '../lib/observability.js';
9
10
  import { validateExistingPath } from '../lib/path-validation.js';
10
11
  import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
11
- import { buildToolErrorResponse, buildToolResponse, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
12
+ import { buildToolErrorResponse, buildToolResponse, createProgressReporter, getExperimentalTaskRegistration, notifyProgress, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
13
+ import { createToolTaskHandler } from './task-support.js';
12
14
  const WINDOWS_PATH_SEPARATOR = /\\/gu;
13
15
  const CALCULATE_HASH_TOOL = {
14
16
  title: 'Calculate Hash',
@@ -52,9 +54,19 @@ function updateCompositeHash(hasher, pathLengthBytes, relativePath, fileHash) {
52
54
  hasher.update(relativePathBytes);
53
55
  hasher.update(fileHash);
54
56
  }
55
- async function hashDirectory(dirPath, signal) {
56
- // Enumerate all files in directory (respects .gitignore by default)
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
+ async function hashDirectory(dirPath, options = {}) {
65
+ const { signal, onProgress } = options;
66
+ const gitignoreMatcher = await loadRootGitignore(dirPath, signal);
67
+ // Enumerate all files in directory.
57
68
  const entries = [];
69
+ let filesHashed = 0;
58
70
  for await (const entry of globEntries({
59
71
  cwd: dirPath,
60
72
  pattern: '**/*',
@@ -68,12 +80,19 @@ async function hashDirectory(dirPath, signal) {
68
80
  suppressErrors: true,
69
81
  })) {
70
82
  assertNotAborted(signal);
83
+ if (gitignoreMatcher &&
84
+ isIgnoredByGitignore(gitignoreMatcher, dirPath, entry.path)) {
85
+ continue;
86
+ }
71
87
  // entry.path is already absolute, no need to join
72
88
  const fileHash = await hashFile(entry.path, undefined, signal);
73
89
  // Use posix separators so hashes are stable across OS path separators.
74
90
  const relativePath = toStableRelativePath(dirPath, entry.path);
75
91
  entries.push({ path: relativePath, hash: fileHash });
92
+ filesHashed++;
93
+ reportHashProgress(onProgress, filesHashed);
76
94
  }
95
+ reportHashProgress(onProgress, filesHashed, true);
77
96
  assertNotAborted(signal);
78
97
  // Sort by path with byte-wise semantics for deterministic ordering.
79
98
  entries.sort(comparePaths);
@@ -89,13 +108,16 @@ async function hashDirectory(dirPath, signal) {
89
108
  fileCount: entries.length,
90
109
  };
91
110
  }
92
- async function handleCalculateHash(args, signal) {
111
+ async function handleCalculateHash(args, signal, onProgress) {
93
112
  const validPath = await validateExistingPath(args.path, signal);
94
113
  // Check if path is a directory or file
95
114
  const stats = await withAbort(fs.stat(validPath), signal);
96
115
  if (stats.isDirectory()) {
97
116
  // Hash directory: composite hash of all files
98
- const { hash, fileCount } = await hashDirectory(validPath, signal);
117
+ const { hash, fileCount } = await hashDirectory(validPath, {
118
+ ...(signal ? { signal } : {}),
119
+ ...(onProgress ? { onProgress } : {}),
120
+ });
99
121
  return buildToolResponse(`${hash} (${fileCount} files)`, {
100
122
  ok: true,
101
123
  path: validPath,
@@ -107,6 +129,7 @@ async function handleCalculateHash(args, signal) {
107
129
  else {
108
130
  // Hash single file
109
131
  const hash = await hashFile(validPath, 'hex', signal);
132
+ reportHashProgress(onProgress, 1, true);
110
133
  return buildToolResponse(hash, {
111
134
  ok: true,
112
135
  path: validPath,
@@ -117,19 +140,42 @@ async function handleCalculateHash(args, signal) {
117
140
  }
118
141
  export function registerCalculateHashTool(server, options = {}) {
119
142
  const handler = (args, extra) => withToolDiagnostics('calculate_hash', () => withToolErrorHandling(async () => {
143
+ notifyProgress(extra, {
144
+ current: 0,
145
+ message: `🕮 calculate_hash: ${path.basename(args.path)}`,
146
+ });
120
147
  const { signal, cleanup } = createTimedAbortSignal(extra.signal);
121
148
  try {
122
- return await handleCalculateHash(args, signal);
149
+ const result = await handleCalculateHash(args, signal, createProgressReporter(extra));
150
+ const sc = result.structuredContent;
151
+ const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
152
+ const finalCurrent = totalFiles + 1;
153
+ const suffix = sc.ok
154
+ ? `${(sc.hash ?? '').slice(0, 8)}...`
155
+ : 'failed';
156
+ notifyProgress(extra, {
157
+ current: finalCurrent,
158
+ message: `🕮 calculate_hash: ${path.basename(args.path)} ➟ ${suffix}`,
159
+ });
160
+ return result;
123
161
  }
124
162
  finally {
125
163
  cleanup();
126
164
  }
127
165
  }, (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path)), { path: args.path });
128
- server.registerTool('calculate_hash', withDefaultIcons({ ...CALCULATE_HASH_TOOL }, options.iconInfo), wrapToolHandler(handler, {
166
+ const wrappedHandler = wrapToolHandler(handler, {
129
167
  guard: options.isInitialized,
130
- progressMessage: (args) => {
131
- const name = path.basename(args.path);
132
- return `🕮 calculate_hash: ${name}`;
133
- },
134
- }));
168
+ });
169
+ const taskOptions = options.isInitialized
170
+ ? { guard: options.isInitialized }
171
+ : undefined;
172
+ const tasks = getExperimentalTaskRegistration(server);
173
+ if (tasks?.registerToolTask) {
174
+ tasks.registerToolTask('calculate_hash', withDefaultIcons({
175
+ ...CALCULATE_HASH_TOOL,
176
+ execution: { taskSupport: 'optional' },
177
+ }, options.iconInfo), createToolTaskHandler(wrappedHandler, taskOptions));
178
+ return;
179
+ }
180
+ server.registerTool('calculate_hash', withDefaultIcons({ ...CALCULATE_HASH_TOOL }, options.iconInfo), wrappedHandler);
135
181
  }
@@ -54,10 +54,9 @@ export interface ToolRegistrationOptions {
54
54
  serverIcon?: string;
55
55
  iconInfo?: IconInfo;
56
56
  }
57
- export interface ExperimentalTaskRegistration {
57
+ export declare function getExperimentalTaskRegistration(server: McpServer): {
58
58
  registerToolTask?: (...args: unknown[]) => unknown;
59
- }
60
- export declare function getExperimentalTaskRegistration(server: McpServer): ExperimentalTaskRegistration | undefined;
59
+ } | undefined;
61
60
  export declare function withToolErrorHandling<T>(run: () => Promise<ToolResponse<T>>, onError: (error: unknown) => ToolResult<T>): Promise<ToolResult<T>>;
62
61
  export declare function buildToolErrorResponse(error: unknown, defaultCode: ErrorCode, path?: string): ToolErrorResponse;
63
62
  export declare function createProgressReporter(extra: ToolExtra): (progress: {
@@ -1,6 +1,8 @@
1
1
  import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { ErrorCode, McpError } from '../lib/errors.js';
3
3
  import { buildToolErrorResponse } from './shared.js';
4
+ const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task';
5
+ const TASK_STATUS_NOTIFICATION_METHOD = 'notifications/tasks/status';
4
6
  function isRecord(value) {
5
7
  return value !== null && typeof value === 'object';
6
8
  }
@@ -87,6 +89,60 @@ function normalizeCallToolResult(value) {
87
89
  return parsed.data;
88
90
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'Stored task result is not a valid tool result.');
89
91
  }
92
+ function withRelatedTaskMeta(result, taskId) {
93
+ if (!isRecord(result)) {
94
+ return {
95
+ _meta: {
96
+ [RELATED_TASK_META_KEY]: { taskId },
97
+ },
98
+ };
99
+ }
100
+ const existingMeta = isRecord(result['_meta']) ? result['_meta'] : {};
101
+ return {
102
+ ...result,
103
+ _meta: {
104
+ ...existingMeta,
105
+ [RELATED_TASK_META_KEY]: { taskId },
106
+ },
107
+ };
108
+ }
109
+ function getTaskStatusNotificationSender(extra) {
110
+ const candidate = extra.sendNotification;
111
+ return typeof candidate === 'function'
112
+ ? candidate
113
+ : undefined;
114
+ }
115
+ function buildTaskStatusNotificationParams(task) {
116
+ return {
117
+ taskId: task.taskId,
118
+ status: task.status,
119
+ ttl: task.ttl,
120
+ createdAt: task.createdAt,
121
+ lastUpdatedAt: task.lastUpdatedAt,
122
+ ...(task.pollInterval !== undefined
123
+ ? { pollInterval: task.pollInterval }
124
+ : {}),
125
+ ...(task.statusMessage !== undefined
126
+ ? { statusMessage: task.statusMessage }
127
+ : {}),
128
+ };
129
+ }
130
+ async function notifyTaskStatusIfPossible(extra, taskStore, taskId) {
131
+ const sendNotification = getTaskStatusNotificationSender(extra);
132
+ if (!sendNotification)
133
+ return;
134
+ try {
135
+ const task = await taskStore.getTask(taskId);
136
+ const normalized = normalizeGetTaskResult(task);
137
+ await sendNotification({
138
+ method: TASK_STATUS_NOTIFICATION_METHOD,
139
+ params: buildTaskStatusNotificationParams(normalized),
140
+ });
141
+ }
142
+ catch {
143
+ // Never fail task execution because status notifications are optional.
144
+ }
145
+ }
90
146
  function getTaskStore(extra) {
91
147
  if (!extra.taskStore) {
92
148
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'Task store not configured for task-capable tool.');
@@ -130,11 +186,12 @@ async function getCurrentTaskStatus(taskStore, taskId) {
130
186
  }
131
187
  }
132
188
  async function tryStoreTaskResult(taskStore, taskId, status, result) {
189
+ const resultWithTaskMeta = withRelatedTaskMeta(result, taskId);
133
190
  const beforeStatus = await getCurrentTaskStatus(taskStore, taskId);
134
191
  if (isTerminalTaskStatus(beforeStatus))
135
192
  return;
136
193
  try {
137
- await taskStore.storeTaskResult(taskId, status, result);
194
+ await taskStore.storeTaskResult(taskId, status, resultWithTaskMeta);
138
195
  }
139
196
  catch (error) {
140
197
  const afterStatus = await getCurrentTaskStatus(taskStore, taskId);
@@ -160,16 +217,19 @@ export function createToolTaskHandler(run, options) {
160
217
  taskStore,
161
218
  taskId: task.taskId,
162
219
  };
220
+ void notifyTaskStatusIfPossible(taskExtra, taskStore, task.taskId);
163
221
  void (async () => {
164
222
  try {
165
223
  const result = await run(args, taskExtra);
166
224
  const status = isErrorResult(result) ? 'failed' : 'completed';
167
225
  await tryStoreTaskResult(taskStore, task.taskId, status, result);
226
+ await notifyTaskStatusIfPossible(taskExtra, taskStore, task.taskId);
168
227
  }
169
228
  catch (error) {
170
229
  const fallback = buildToolErrorResponse(error, ErrorCode.E_UNKNOWN);
171
230
  try {
172
231
  await tryStoreTaskResult(taskStore, task.taskId, 'failed', fallback);
232
+ await notifyTaskStatusIfPossible(taskExtra, taskStore, task.taskId);
173
233
  }
174
234
  catch {
175
235
  // Swallow to avoid unhandled rejections from background task writes.
@@ -190,7 +250,7 @@ export function createToolTaskHandler(run, options) {
190
250
  const taskStore = getTaskStore(extra);
191
251
  const taskId = getTaskId(extra);
192
252
  const result = await taskStore.getTaskResult(taskId);
193
- return normalizeCallToolResult(result);
253
+ return normalizeCallToolResult(withRelatedTaskMeta(result, taskId));
194
254
  });
195
255
  return {
196
256
  createTask,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.1.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",
@@ -73,7 +73,6 @@
73
73
  "devDependencies": {
74
74
  "@eslint/js": "^9.39.2",
75
75
  "@trivago/prettier-plugin-sort-imports": "^6.0.2",
76
- "@types/diff": "^7.0.2",
77
76
  "@types/node": "^24",
78
77
  "eslint": "^9.39.2",
79
78
  "eslint-config-prettier": "^10.1.8",