@j0hanz/filesystem-mcp 1.16.3 → 1.17.1

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.
@@ -1,5 +1,6 @@
1
- import { readdir, stat } from 'node:fs/promises';
1
+ import { readdir, realpath, stat } from 'node:fs/promises';
2
2
  import { basename, dirname, isAbsolute, join, parse, resolve, sep, } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import { getAllowedDirectories, isPathWithinDirectories, normalizePath, toPosixPath, } from './lib/paths.js';
4
5
  import { isRecord } from './lib/utils.js';
5
6
  import { getSortedToolContracts } from './resources/tool-info.js';
@@ -46,9 +47,63 @@ const PATH_ARGUMENTS = new Set([
46
47
  const DESTINATION_CONTEXT_KEYS = ['source', 'path', 'cwd', 'root'];
47
48
  const PRIMARY_PATH_CONTEXT_KEYS = ['path', 'cwd', 'root'];
48
49
  const DEFAULT_CONTEXT_KEYS = ['path', 'source', 'cwd', 'root'];
49
- const ENUM_ARGUMENT_VALUES = new Map([
50
- ['sortby', ['modified', 'name', 'path', 'size', 'type']],
51
- ]);
50
+ function extractEnumValuesFromSchema(argumentName, schema) {
51
+ const properties = getInputSchemaProperties(schema);
52
+ if (!properties || typeof properties !== 'object')
53
+ return undefined;
54
+ for (const [key, value] of Object.entries(properties)) {
55
+ if (key.toLowerCase() !== argumentName)
56
+ continue;
57
+ if (value === null || typeof value !== 'object' || !('enum' in value)) {
58
+ return undefined;
59
+ }
60
+ const enumValues = value.enum;
61
+ if (!Array.isArray(enumValues))
62
+ return undefined;
63
+ const stringValues = enumValues.filter((entry) => typeof entry === 'string');
64
+ return stringValues.length > 0 ? stringValues : undefined;
65
+ }
66
+ return undefined;
67
+ }
68
+ function getInputSchemaProperties(schema) {
69
+ const { properties } = z.toJSONSchema(schema, { io: 'input' });
70
+ if (!properties || typeof properties !== 'object')
71
+ return undefined;
72
+ return properties;
73
+ }
74
+ function intersectEnumValueSets(valueSets) {
75
+ const [firstSet, ...restSets] = valueSets;
76
+ if (!firstSet)
77
+ return [];
78
+ return firstSet.filter((value) => restSets.every((candidateSet) => candidateSet.includes(value)));
79
+ }
80
+ function buildEnumArgumentValues() {
81
+ const valuesByArgument = new Map();
82
+ for (const contract of getSortedToolContracts()) {
83
+ const properties = getInputSchemaProperties(contract.inputSchema);
84
+ if (!properties || typeof properties !== 'object')
85
+ continue;
86
+ for (const key of Object.keys(properties)) {
87
+ const normalizedKey = key.toLowerCase();
88
+ const values = extractEnumValuesFromSchema(normalizedKey, contract.inputSchema);
89
+ if (!values)
90
+ continue;
91
+ const existing = valuesByArgument.get(normalizedKey) ?? [];
92
+ existing.push(values);
93
+ valuesByArgument.set(normalizedKey, existing);
94
+ }
95
+ }
96
+ const result = new Map();
97
+ for (const [argumentName, valueSets] of valuesByArgument) {
98
+ const values = valueSets.length === 1
99
+ ? (valueSets[0] ?? [])
100
+ : intersectEnumValueSets(valueSets);
101
+ if (values.length > 0) {
102
+ result.set(argumentName, values);
103
+ }
104
+ }
105
+ return result;
106
+ }
52
107
  function isPathLikeArgumentName(argName) {
53
108
  return (PATH_ARGUMENTS.has(argName) ||
54
109
  argName.endsWith('paths') ||
@@ -58,8 +113,8 @@ function isPathLikeArgumentName(argName) {
58
113
  argName.endsWith('dirs') ||
59
114
  argName.endsWith('dir'));
60
115
  }
61
- function getEnumCompletions(argName, currentValue) {
62
- const values = ENUM_ARGUMENT_VALUES.get(argName);
116
+ function getEnumCompletions(argName, currentValue, enumArgumentValues) {
117
+ const values = enumArgumentValues.get(argName);
63
118
  if (!values)
64
119
  return undefined;
65
120
  const prefix = currentValue.toLowerCase();
@@ -267,18 +322,29 @@ function resolveContextCandidatePath(candidate, allowed) {
267
322
  return resolveNamedRootPath(candidate, allowed);
268
323
  }
269
324
  async function toAllowedContextDirectory(resolved, allowed) {
270
- if (!isPathWithinDirectories(resolved, allowed))
271
- return undefined;
325
+ const parent = dirname(resolved);
326
+ if (await isAllowedCompletionDirectory(resolved, allowed)) {
327
+ return resolved;
328
+ }
329
+ return (await isAllowedCompletionDirectory(parent, allowed))
330
+ ? parent
331
+ : undefined;
332
+ }
333
+ async function isAllowedCompletionDirectory(path, allowed) {
334
+ if (!isPathWithinDirectories(path, allowed))
335
+ return false;
272
336
  try {
273
- const stats = await stat(resolved);
274
- if (stats.isDirectory())
275
- return resolved;
337
+ const [stats, resolvedRealPath] = await Promise.all([
338
+ stat(path),
339
+ realpath(path),
340
+ ]);
341
+ if (!stats.isDirectory())
342
+ return false;
343
+ return isPathWithinDirectories(normalizePath(resolvedRealPath), allowed);
276
344
  }
277
345
  catch {
278
- // Fall back to parent path best-effort resolution.
346
+ return false;
279
347
  }
280
- const parent = dirname(resolved);
281
- return isPathWithinDirectories(parent, allowed) ? parent : undefined;
282
348
  }
283
349
  async function resolveContextBaseDirectory(argumentName, contextArguments, allowed) {
284
350
  if (!hasContextArguments(contextArguments)) {
@@ -371,7 +437,7 @@ function getSearchContext(currentValue, allowed, contextBase) {
371
437
  }
372
438
  async function findMatchesInDirectory(searchDir, prefix, allowed) {
373
439
  const matches = [];
374
- if (!isPathWithinDirectories(searchDir, allowed)) {
440
+ if (!(await isAllowedCompletionDirectory(searchDir, allowed))) {
375
441
  return matches;
376
442
  }
377
443
  try {
@@ -460,6 +526,7 @@ function handleTopicAndToolCompletions(ref, argName, argumentValue, topicValues,
460
526
  export function registerCompletions(server, instructions = '') {
461
527
  const topicValues = extractTopicCompletions(instructions);
462
528
  const toolNameValues = extractToolNameCompletions();
529
+ const enumArgumentValues = buildEnumArgumentValues();
463
530
  server.server.setRequestHandler('completion/complete', async (request) => {
464
531
  const { params } = request;
465
532
  const { argument, ref } = params;
@@ -467,7 +534,7 @@ export function registerCompletions(server, instructions = '') {
467
534
  const predef = handleTopicAndToolCompletions(ref, argName, argument.value, topicValues, toolNameValues);
468
535
  if (predef)
469
536
  return predef;
470
- const enumResult = getEnumCompletions(argName, argument.value);
537
+ const enumResult = getEnumCompletions(argName, argument.value, enumArgumentValues);
471
538
  if (enumResult) {
472
539
  return buildCompletionResponse(enumResult);
473
540
  }
@@ -5,7 +5,7 @@ export declare const DEFAULT_TASK_TTL_MS: number;
5
5
  export declare const MAX_TASK_TTL_MS: number;
6
6
  export declare const MAX_CONCURRENT_TASKS: number;
7
7
  export declare const TASK_CANCEL_POLL_MS = 2000;
8
- export declare const INIT_HANDSHAKE_TIMEOUT_MS: number;
8
+ export declare function getInitHandshakeTimeoutMs(): number;
9
9
  export declare const INIT_TIMEOUT_CLOSE: boolean;
10
10
  export declare const TASK_POLL_INTERVAL_MS = 100;
11
11
  export declare const PARALLEL_CONCURRENCY: number;
@@ -76,7 +76,9 @@ export const DEFAULT_TASK_TTL_MS = 5 * 60 * 1000;
76
76
  export const MAX_TASK_TTL_MS = parseEnvInt('FILESYSTEM_MCP_MAX_TASK_TTL_MS', 60 * 60 * 1000, 1_000, 24 * 60 * 60 * 1000);
77
77
  export const MAX_CONCURRENT_TASKS = parseEnvInt('FILESYSTEM_MCP_MAX_CONCURRENT_TASKS', 100, 1, 10_000);
78
78
  export const TASK_CANCEL_POLL_MS = 2_000;
79
- export const INIT_HANDSHAKE_TIMEOUT_MS = parseEnvInt('FS_INIT_HANDSHAKE_TIMEOUT_MS', 30_000, 1_000, 300_000);
79
+ export function getInitHandshakeTimeoutMs() {
80
+ return parseEnvInt('FS_INIT_HANDSHAKE_TIMEOUT_MS', 30_000, 1_000, 300_000);
81
+ }
80
82
  export const INIT_TIMEOUT_CLOSE = parseTrueEnvFlag(process.env['FS_INIT_TIMEOUT_CLOSE']);
81
83
  export const TASK_POLL_INTERVAL_MS = 100;
82
84
  // Auto-tuned parallelism based on CPU cores (no env override)
@@ -1,4 +1,4 @@
1
- import type { Stats } from 'node:fs';
1
+ import { type Stats } from 'node:fs';
2
2
  import { type FileHandle } from 'node:fs/promises';
3
3
  import type { FileType } from '../config.js';
4
4
  interface ParallelResult<R> {
@@ -12,6 +12,7 @@ export declare function processInParallel<T, R>(items: T[], processor: (item: T)
12
12
  export declare function getFileType(stats: Stats): FileType;
13
13
  export declare function isHidden(name: string): boolean;
14
14
  export declare function isProbablyBinary(filePath: string, existingHandle?: FileHandle, signal?: AbortSignal): Promise<boolean>;
15
+ export declare function calculateFileContentHash(filePath: string, signal?: AbortSignal): Promise<string>;
15
16
  type ReadMode = 'head' | 'full' | 'range' | 'tail';
16
17
  interface ReadFileOptions {
17
18
  encoding?: BufferEncoding;
@@ -51,9 +51,11 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
53
  import { isUtf8 } from 'node:buffer';
54
- import { randomUUID } from 'node:crypto';
54
+ import { createHash, randomUUID } from 'node:crypto';
55
+ import { createReadStream } from 'node:fs';
55
56
  import { open, rename, stat, unlink, writeFile, } from 'node:fs/promises';
56
57
  import { extname } from 'node:path';
58
+ import { pipeline } from 'node:stream/promises';
57
59
  import { assertNotAborted, withAbort } from './abort.js';
58
60
  import { BINARY_CHECK_BUFFER_SIZE, KNOWN_BINARY_EXTENSIONS, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from './constants.js';
59
61
  import { ErrorCode, McpError, normalizeUnknownError } from './errors.js';
@@ -192,6 +194,11 @@ function isBinarySlice(slice) {
192
194
  return true;
193
195
  return !isUtf8(slice);
194
196
  }
197
+ export async function calculateFileContentHash(filePath, signal) {
198
+ const hasher = createHash('sha256');
199
+ await pipeline(createReadStream(filePath, { signal, highWaterMark: STREAM_CHUNK_SIZE }), hasher, { signal });
200
+ return hasher.digest('hex');
201
+ }
195
202
  function validateReadOptions(options) {
196
203
  const hasHead = options.head !== undefined;
197
204
  const hasTail = options.tail !== undefined;
@@ -298,24 +305,6 @@ async function readFileBufferWithLimit(handle, maxSize, requestedPath, signal) {
298
305
  }
299
306
  return Buffer.concat(chunks, totalSize);
300
307
  }
301
- async function headFile(handle, numLines, encoding = 'utf-8', maxBytesRead, signal) {
302
- assertNotAborted(signal);
303
- const lines = [];
304
- let estimatedBytes = 0;
305
- const hasMaxBytes = maxBytesRead !== undefined;
306
- const newlineBytes = Buffer.byteLength('\n', encoding);
307
- for await (const line of handle.readLines({ encoding, signal })) {
308
- lines.push(line);
309
- if (lines.length >= numLines)
310
- break;
311
- if (!hasMaxBytes)
312
- continue;
313
- estimatedBytes += Buffer.byteLength(line, encoding) + newlineBytes;
314
- if (estimatedBytes >= maxBytesRead)
315
- break;
316
- }
317
- return lines.join('\n');
318
- }
319
308
  function countLines(content) {
320
309
  if (content.length === 0)
321
310
  return 0;
@@ -327,9 +316,37 @@ function countLines(content) {
327
316
  return count;
328
317
  }
329
318
  async function readHeadContent(handle, head, options) {
330
- const content = await headFile(handle, head, options.encoding, options.maxSize, options.signal);
331
- const linesRead = countLines(content);
332
- const hasMoreLines = linesRead >= head;
319
+ assertNotAborted(options.signal);
320
+ const lines = [];
321
+ let estimatedBytes = 0;
322
+ const newlineBytes = Buffer.byteLength('\n', options.encoding);
323
+ const iterator = handle
324
+ .readLines({ encoding: options.encoding, signal: options.signal })[Symbol.asyncIterator]();
325
+ let hasMoreLines = false;
326
+ let next = await iterator.next();
327
+ try {
328
+ while (!next.done) {
329
+ const line = next.value;
330
+ lines.push(line);
331
+ estimatedBytes +=
332
+ Buffer.byteLength(line, options.encoding) + newlineBytes;
333
+ if (estimatedBytes >= options.maxSize) {
334
+ hasMoreLines = true;
335
+ break;
336
+ }
337
+ if (lines.length === head) {
338
+ const peek = await iterator.next();
339
+ hasMoreLines = !peek.done;
340
+ break;
341
+ }
342
+ next = await iterator.next();
343
+ }
344
+ }
345
+ finally {
346
+ await iterator.return?.();
347
+ }
348
+ const content = lines.join('\n');
349
+ const linesRead = lines.length;
333
350
  return {
334
351
  content,
335
352
  truncated: hasMoreLines,
@@ -4,7 +4,8 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  import { channel } from 'node:diagnostics_channel';
5
5
  import { readFile } from 'node:fs/promises';
6
6
  import { createServer as createHttpServer, } from 'node:http';
7
- import { DEFAULT_LOG_LEVEL, INIT_HANDSHAKE_TIMEOUT_MS, INIT_TIMEOUT_CLOSE, parseEnvInt, } from '../lib/constants.js';
7
+ import { inspect } from 'node:util';
8
+ import { DEFAULT_LOG_LEVEL, getInitHandshakeTimeoutMs, INIT_TIMEOUT_CLOSE, parseEnvInt, } from '../lib/constants.js';
8
9
  import { formatUnknownErrorMessage } from '../lib/errors.js';
9
10
  import { createLoggingState, Logger, logToMcp, SessionContext, } from '../lib/logger.js';
10
11
  import { withAllowedDirectoriesState } from '../lib/paths.js';
@@ -44,9 +45,17 @@ const activeServers = new Map();
44
45
  // For stdio (single session without a specific ID)
45
46
  let stdioServer;
46
47
  function stringifyData(data) {
47
- if (!data)
48
+ if (data === undefined)
48
49
  return '';
49
- return ` ${typeof data === 'string' ? data : JSON.stringify(data)}`;
50
+ if (typeof data === 'string')
51
+ return ` ${data}`;
52
+ if (data === null ||
53
+ typeof data === 'number' ||
54
+ typeof data === 'boolean' ||
55
+ typeof data === 'bigint') {
56
+ return ` ${String(data)}`;
57
+ }
58
+ return ` ${inspect(data, { depth: 4, colors: false, compact: 3 })}`;
50
59
  }
51
60
  channel('filesystem-mcp:log').subscribe((message) => {
52
61
  const event = message;
@@ -533,14 +542,15 @@ export async function startHttpServer(port, options) {
533
542
  handleHttpRequestError(error, res);
534
543
  }
535
544
  }
536
- const SWEEP_INTERVAL_MS = INIT_HANDSHAKE_TIMEOUT_MS * 2;
545
+ const initHandshakeTimeoutMs = getInitHandshakeTimeoutMs();
546
+ const SWEEP_INTERVAL_MS = initHandshakeTimeoutMs * 2;
537
547
  const sweepTimer = setInterval(() => {
538
548
  const now = Date.now();
539
549
  for (const [sessionId, session] of sessions) {
540
550
  if (!session.rootsManager.isInitialized() &&
541
- now - session.createdAt > INIT_HANDSHAKE_TIMEOUT_MS) {
551
+ now - session.createdAt > initHandshakeTimeoutMs) {
542
552
  Logger.warn(`[HTTP] Evicting stale session ${sessionId}: client never sent notifications/initialized`);
543
- session.server.close().catch((err) => {
553
+ session.close().catch((err) => {
544
554
  Logger.error(`[HTTP] Error closing stale session ${sessionId}:`, formatUnknownErrorMessage(err));
545
555
  });
546
556
  }
@@ -3,7 +3,7 @@ import { channel } from 'node:diagnostics_channel';
3
3
  import { realpath } from 'node:fs/promises';
4
4
  import { z } from 'zod';
5
5
  import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/abort.js';
6
- import { INIT_HANDSHAKE_TIMEOUT_MS } from '../lib/constants.js';
6
+ import { getInitHandshakeTimeoutMs } from '../lib/constants.js';
7
7
  import { formatUnknownErrorMessage } from '../lib/errors.js';
8
8
  import { Logger, logToMcp } from '../lib/logger.js';
9
9
  import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
@@ -128,6 +128,7 @@ export class RootsManager {
128
128
  }
129
129
  }
130
130
  registerHandlers(server, onInitTimeout) {
131
+ const initHandshakeTimeoutMs = getInitHandshakeTimeoutMs();
131
132
  server.server.setNotificationHandler('notifications/initialized', async () => {
132
133
  if (this.initTimer) {
133
134
  clearTimeout(this.initTimer);
@@ -146,14 +147,14 @@ export class RootsManager {
146
147
  if (LIFECYCLE_CHANNEL.hasSubscribers) {
147
148
  LIFECYCLE_CHANNEL.publish({
148
149
  phase: 'init_timeout',
149
- timeoutMs: INIT_HANDSHAKE_TIMEOUT_MS,
150
+ timeoutMs: initHandshakeTimeoutMs,
150
151
  });
151
152
  }
152
- logToMcp(server, 'warning', `Client did not send notifications/initialized within ${String(INIT_HANDSHAKE_TIMEOUT_MS)}ms`, this.loggingState.minimumLevel);
153
+ logToMcp(server, 'warning', `Client did not send notifications/initialized within ${String(initHandshakeTimeoutMs)}ms`, this.loggingState.minimumLevel);
153
154
  onInitTimeout?.();
154
155
  }
155
156
  this.initTimer = undefined;
156
- }, INIT_HANDSHAKE_TIMEOUT_MS);
157
+ }, initHandshakeTimeoutMs);
157
158
  this.initTimer.unref();
158
159
  }
159
160
  async recomputeAllowedDirectories() {
@@ -1,10 +1,10 @@
1
- import { readFile, stat } from 'node:fs/promises';
1
+ import { stat } from 'node:fs/promises';
2
2
  import { basename, resolve } from 'node:path';
3
3
  import { applyPatch, parsePatch } from 'diff';
4
4
  import { withAbort } from '../lib/abort.js';
5
5
  import { MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY } from '../lib/constants.js';
6
6
  import { ErrorCode, McpError } from '../lib/errors.js';
7
- import { atomicWriteFile, processInParallel } from '../lib/fs-helpers.js';
7
+ import { atomicWriteFile, processInParallel, readFileWithStats, } from '../lib/fs-helpers.js';
8
8
  import { Logger } from '../lib/logger.js';
9
9
  import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
10
10
  import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
@@ -62,7 +62,12 @@ async function applyDiff(filePath, diff, options, signal) {
62
62
  assertAllowedFileAccess(filePath, validPath);
63
63
  const stats = await withAbort(stat(validPath), signal);
64
64
  assertPatchTargetSizeWithinLimit(validPath, stats.size, MAX_TEXT_FILE_SIZE);
65
- const content = await readFile(validPath, { encoding: 'utf-8', signal });
65
+ const { content } = await readFileWithStats(filePath, validPath, stats, {
66
+ encoding: 'utf-8',
67
+ maxSize: MAX_TEXT_FILE_SIZE,
68
+ skipBinary: true,
69
+ ...(signal ? { signal } : {}),
70
+ });
66
71
  const patched = applyPatch(content, diff, {
67
72
  fuzzFactor: options.fuzzFactor,
68
73
  autoConvertLineEndings: options.autoConvertLineEndings,
@@ -1,11 +1,11 @@
1
- import { readFile, stat } from 'node:fs/promises';
1
+ import { stat } from 'node:fs/promises';
2
2
  import { basename } from 'node:path';
3
3
  import { createTwoFilesPatch, diffLines } from 'diff';
4
4
  import RE2 from 're2';
5
5
  import { withAbort } from '../lib/abort.js';
6
6
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
7
7
  import { ErrorCode, McpError } from '../lib/errors.js';
8
- import { atomicWriteFile } from '../lib/fs-helpers.js';
8
+ import { atomicWriteFile, readFileWithStats } from '../lib/fs-helpers.js';
9
9
  import { Logger } from '../lib/logger.js';
10
10
  import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
11
11
  import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
@@ -167,7 +167,12 @@ async function loadEditableFile(requestedPath, signal) {
167
167
  if (stats.size > MAX_TEXT_FILE_SIZE) {
168
168
  throw new McpError(ErrorCode.TOO_LARGE, `File too large for edit (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, requestedPath, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
169
169
  }
170
- const content = await readFile(validPath, { encoding: 'utf-8', signal });
170
+ const { content } = await readFileWithStats(requestedPath, validPath, stats, {
171
+ encoding: 'utf-8',
172
+ maxSize: MAX_TEXT_FILE_SIZE,
173
+ skipBinary: true,
174
+ ...(signal ? { signal } : {}),
175
+ });
171
176
  return { validPath, content };
172
177
  }
173
178
  function buildEditProgressMessage(args) {
@@ -1,8 +1,7 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { basename } from 'node:path';
3
2
  import { DEFAULT_SEARCH_TIMEOUT_MS, MAX_TEXT_FILE_SIZE, } from '../lib/constants.js';
4
3
  import { ErrorCode } from '../lib/errors.js';
5
- import { readFile } from '../lib/fs-helpers.js';
4
+ import { calculateFileContentHash, readFile } from '../lib/fs-helpers.js';
6
5
  import { ReadFileInputSchema, ReadFileOutputSchema } from '../schemas.js';
7
6
  import { FILE_READ_ICONS } from './icons.js';
8
7
  import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, } from './shared.js';
@@ -144,9 +143,7 @@ async function handleReadFile(args, signal, resourceStore) {
144
143
  const result = await readFile(args.path, options);
145
144
  const structured = toStructuredReadFileResult(args, result);
146
145
  if (args.includeHash) {
147
- structured.contentHash = createHash('sha256')
148
- .update(result.content, 'utf-8')
149
- .digest('hex');
146
+ structured.contentHash = await calculateFileContentHash(result.path, signal);
150
147
  }
151
148
  const externalizedResponse = maybeBuildExternalizedReadResponse(args.path, result.content, structured, resourceStore);
152
149
  if (externalizedResponse) {
@@ -263,6 +263,19 @@ const TERMINAL_TASK_STATUSES = new Set([
263
263
  'cancelled',
264
264
  'unknown',
265
265
  ]);
266
+ const taskCreationLocks = new WeakMap();
267
+ async function acquireTaskCreationLock(taskStore) {
268
+ const previous = taskCreationLocks.get(taskStore) ?? Promise.resolve();
269
+ let release;
270
+ const next = new Promise((resolve) => {
271
+ release = resolve;
272
+ });
273
+ taskCreationLocks.set(taskStore, previous.catch(() => { }).then(() => next));
274
+ await previous.catch(() => { });
275
+ return () => {
276
+ release();
277
+ };
278
+ }
266
279
  async function isTaskAlreadyTerminal(taskStore, taskId) {
267
280
  try {
268
281
  const task = await taskStore.getTask(taskId);
@@ -443,13 +456,20 @@ export function createToolTaskHandler(run, options) {
443
456
  throw new McpError(ErrorCode.INVALID_INPUT, 'Client not initialized; wait for notifications/initialized');
444
457
  }
445
458
  const taskStore = getTaskStore(ctx);
446
- if ((await countActiveTasks(taskStore)) >= MAX_CONCURRENT_TASKS) {
447
- throw new McpError(ErrorCode.INVALID_INPUT, `Too many active tasks (limit: ${String(MAX_CONCURRENT_TASKS)}).`);
459
+ const releaseCreationLock = await acquireTaskCreationLock(taskStore);
460
+ let task;
461
+ try {
462
+ if ((await countActiveTasks(taskStore)) >= MAX_CONCURRENT_TASKS) {
463
+ throw new McpError(ErrorCode.INVALID_INPUT, `Too many active tasks (limit: ${String(MAX_CONCURRENT_TASKS)}).`);
464
+ }
465
+ task = await taskStore.createTask({
466
+ ttl: resolveRequestedTaskTtl(ctx.taskRequestedTtl),
467
+ pollInterval: options?.pollIntervalMs ?? TASK_POLL_INTERVAL_MS,
468
+ });
469
+ }
470
+ finally {
471
+ releaseCreationLock();
448
472
  }
449
- const task = await taskStore.createTask({
450
- ttl: resolveRequestedTaskTtl(ctx.taskRequestedTtl),
451
- pollInterval: options?.pollIntervalMs ?? TASK_POLL_INTERVAL_MS,
452
- });
453
473
  const toolLabel = options?.toolName ?? 'tool';
454
474
  try {
455
475
  await taskStore.updateTaskStatus(task.taskId, 'working', `${toolLabel}: starting`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.16.3",
3
+ "version": "1.17.1",
4
4
  "mcpName": "io.github.j0hanz/filesystem-mcp",
5
5
  "description": "Secure filesystem MCP server for reading, writing, searching, diffing, and patching files.",
6
6
  "author": "j0hanz",