@j0hanz/filesystem-mcp 1.11.1 → 1.13.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.
@@ -5,7 +5,7 @@ import { InMemoryTaskMessageQueue, InMemoryTaskStore, } from '@modelcontextproto
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
- import { isInitializeRequest, SetLevelRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
8
+ import { isInitializeRequest, LATEST_PROTOCOL_VERSION, SetLevelRequestSchema, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { DEFAULT_LOG_LEVEL, parseEnvInt } from '../lib/constants.js';
10
10
  import { formatUnknownErrorMessage } from '../lib/errors.js';
11
11
  import { withAllowedDirectoriesState } from '../lib/paths.js';
@@ -243,7 +243,7 @@ async function readRequestBody(req) {
243
243
  req.on('error', reject);
244
244
  });
245
245
  }
246
- async function createHttpSession(options, sessions) {
246
+ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
247
247
  const mcpServer = await createServer(options);
248
248
  const rootsManager = getRootsManager(mcpServer);
249
249
  rootsManager.registerHandlers(mcpServer);
@@ -255,6 +255,7 @@ async function createHttpSession(options, sessions) {
255
255
  server: mcpServer,
256
256
  rootsManager,
257
257
  transport,
258
+ negotiatedProtocolVersion,
258
259
  });
259
260
  rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
260
261
  },
@@ -270,7 +271,12 @@ async function createHttpSession(options, sessions) {
270
271
  });
271
272
  };
272
273
  await mcpServer.connect(transport);
273
- return { server: mcpServer, rootsManager, transport };
274
+ return {
275
+ server: mcpServer,
276
+ rootsManager,
277
+ transport,
278
+ negotiatedProtocolVersion,
279
+ };
274
280
  }
275
281
  function sendJsonRpcError(res, status, code, message) {
276
282
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -299,6 +305,29 @@ function getSessionId(req) {
299
305
  ? rawSessionId
300
306
  : undefined;
301
307
  }
308
+ function getProtocolVersionHeader(req) {
309
+ const rawProtocolVersion = req.headers['mcp-protocol-version'];
310
+ return typeof rawProtocolVersion === 'string'
311
+ ? rawProtocolVersion
312
+ : undefined;
313
+ }
314
+ function resolveNegotiatedProtocolVersion(requestedVersion) {
315
+ return SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
316
+ ? requestedVersion
317
+ : LATEST_PROTOCOL_VERSION;
318
+ }
319
+ function ensureSessionProtocolVersion(req, res, session) {
320
+ const protocolVersion = getProtocolVersionHeader(req);
321
+ if (!protocolVersion) {
322
+ sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: Missing MCP-Protocol-Version header');
323
+ return false;
324
+ }
325
+ if (protocolVersion !== session.negotiatedProtocolVersion) {
326
+ sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, `Bad Request: MCP-Protocol-Version must match negotiated version ${session.negotiatedProtocolVersion}`);
327
+ return false;
328
+ }
329
+ return true;
330
+ }
302
331
  function isAuthorizedBearer(apiKey, authHeader) {
303
332
  const bearerPrefix = 'Bearer ';
304
333
  if (typeof authHeader !== 'string' || !authHeader.startsWith(bearerPrefix)) {
@@ -385,6 +414,10 @@ export async function startHttpServer(port, options) {
385
414
  discardRequestBody(req);
386
415
  return;
387
416
  }
417
+ if (!ensureSessionProtocolVersion(req, res, session)) {
418
+ discardRequestBody(req);
419
+ return;
420
+ }
388
421
  const body = await readRequestBody(req);
389
422
  await handleSessionTransportRequest(session, req, res, body);
390
423
  return;
@@ -396,7 +429,7 @@ export async function startHttpServer(port, options) {
396
429
  sendJsonRpcError(res, 503, JSON_RPC_SERVER_ERROR, 'Too many sessions');
397
430
  return;
398
431
  }
399
- const session = await createHttpSession(options, sessions);
432
+ const session = await createHttpSession(options, sessions, resolveNegotiatedProtocolVersion(body.params.protocolVersion));
400
433
  await handleSessionTransportRequest(session, req, res, body);
401
434
  return;
402
435
  }
@@ -411,6 +444,8 @@ export async function startHttpServer(port, options) {
411
444
  const session = getSessionOrRespondNotFound(sessions, sessionId, res);
412
445
  if (!session)
413
446
  return;
447
+ if (!ensureSessionProtocolVersion(req, res, session))
448
+ return;
414
449
  await handleSessionTransportRequest(session, req, res);
415
450
  }
416
451
  else {
@@ -3,7 +3,7 @@ import { type AllowedDirectoriesState } from '../lib/paths.js';
3
3
  import { type LoggingState } from './bootstrap.js';
4
4
  import type { ServerOptions } from './bootstrap.js';
5
5
  export declare class RootsManager {
6
- private rootsUpdateTimeout;
6
+ private _debouncedUpdate;
7
7
  private rootDirectories;
8
8
  private allowedDirectoriesState;
9
9
  private clientInitialized;
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { formatUnknownErrorMessage } from '../lib/errors.js';
5
5
  import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
6
6
  import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
7
- import { isRecord } from '../lib/utils.js';
7
+ import { debounce, isRecord } from '../lib/utils.js';
8
8
  import { logToMcp } from './bootstrap.js';
9
9
  const ROOTS_TIMEOUT_MS = 5000;
10
10
  const ROOTS_DEBOUNCE_MS = 100;
@@ -81,7 +81,7 @@ async function filterRootsWithinBaseline(roots, baseline, signal) {
81
81
  });
82
82
  }
83
83
  export class RootsManager {
84
- rootsUpdateTimeout;
84
+ _debouncedUpdate;
85
85
  rootDirectories = [];
86
86
  allowedDirectoriesState = {
87
87
  primary: [],
@@ -102,9 +102,9 @@ export class RootsManager {
102
102
  return this.clientInitialized;
103
103
  }
104
104
  destroy() {
105
- if (this.rootsUpdateTimeout) {
106
- clearTimeout(this.rootsUpdateTimeout);
107
- this.rootsUpdateTimeout = undefined;
105
+ if (this._debouncedUpdate) {
106
+ this._debouncedUpdate.cancel();
107
+ this._debouncedUpdate = undefined;
108
108
  }
109
109
  }
110
110
  getAllowedDirectoriesState() {
@@ -149,15 +149,10 @@ export class RootsManager {
149
149
  }
150
150
  }
151
151
  scheduleRootsUpdate(server) {
152
- if (this.rootsUpdateTimeout) {
153
- this.rootsUpdateTimeout.refresh();
154
- return;
155
- }
156
- this.rootsUpdateTimeout = setTimeout(() => {
157
- this.rootsUpdateTimeout = undefined;
158
- void this.updateRootsFromClient(server);
152
+ this._debouncedUpdate ??= debounce((s) => {
153
+ void this.updateRootsFromClient(s);
159
154
  }, ROOTS_DEBOUNCE_MS);
160
- this.rootsUpdateTimeout.unref();
155
+ this._debouncedUpdate(server);
161
156
  }
162
157
  logMissingDirectories(server) {
163
158
  if (this.options.allowCwd) {
@@ -1,9 +1,9 @@
1
- import * as fs from 'node:fs/promises';
2
1
  import * as path from 'node:path';
2
+ import { readFile, stat } from 'node:fs/promises';
3
3
  import { applyPatch, parsePatch } from 'diff';
4
- import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
4
+ import { MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY } from '../lib/constants.js';
5
5
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
6
- import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
6
+ import { atomicWriteFile, processInParallel, withAbort, } from '../lib/fs-helpers.js';
7
7
  import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
8
8
  import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
9
9
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -29,15 +29,6 @@ function assertPatchTargetSizeWithinLimit(filePath, size, maxFileSize) {
29
29
  return;
30
30
  throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for patch: ${filePath} (${size} bytes > ${maxFileSize} bytes).`, filePath, { size, maxFileSize });
31
31
  }
32
- function assertPatchHasHunks(patch) {
33
- if (!patch.trim()) {
34
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch content is empty.');
35
- }
36
- const hasHunk = /@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/u.test(patch);
37
- if (!hasHunk) {
38
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch must include unified hunk headers (e.g., @@ -1,2 +1,2 @@).');
39
- }
40
- }
41
32
  function countStructuredPatchStats(diff) {
42
33
  let linesAdded = 0;
43
34
  let linesRemoved = 0;
@@ -63,12 +54,12 @@ function extractPatchTargetPath(diff) {
63
54
  return stripGitPrefix(diff.oldFileName);
64
55
  return undefined;
65
56
  }
66
- async function applyPatchToFile(filePath, diff, options, signal) {
57
+ async function applyDiff(filePath, diff, options, signal) {
67
58
  const validPath = await validateExistingPath(filePath, signal);
68
59
  assertAllowedFileAccess(filePath, validPath);
69
- const stats = await withAbort(fs.stat(validPath), signal);
60
+ const stats = await withAbort(stat(validPath), signal);
70
61
  assertPatchTargetSizeWithinLimit(validPath, stats.size, MAX_TEXT_FILE_SIZE);
71
- const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
62
+ const content = await readFile(validPath, { encoding: 'utf-8', signal });
72
63
  const patched = applyPatch(content, diff, {
73
64
  fuzzFactor: options.fuzzFactor,
74
65
  autoConvertLineEndings: options.autoConvertLineEndings,
@@ -89,32 +80,33 @@ async function applyPatchToFile(filePath, diff, options, signal) {
89
80
  }
90
81
  return { path: validPath, applied: true, ...patchStats };
91
82
  }
92
- async function handleMultiFilePatch(basePath, parsed, options, signal) {
83
+ async function processMultiFilePatch(basePath, parsed, options, signal) {
93
84
  const validBase = await validateExistingPath(basePath, signal);
94
- const results = [];
95
- for (const diff of parsed) {
85
+ const promises = parsed.map((diff) => {
96
86
  const fileName = extractPatchTargetPath(diff);
97
87
  if (!fileName) {
98
- results.push({
88
+ return () => Promise.resolve({
99
89
  path: '<unknown>',
100
90
  applied: false,
101
91
  error: 'Missing file name in patch header',
102
92
  });
103
- continue;
104
93
  }
105
94
  const filePath = path.resolve(validBase, fileName);
106
- try {
107
- const result = await applyPatchToFile(filePath, diff, options, signal);
108
- results.push({ ...result, path: fileName });
109
- }
110
- catch (error) {
111
- results.push({
112
- path: fileName,
113
- applied: false,
114
- error: formatUnknownErrorMessage(error),
115
- });
116
- }
117
- }
95
+ return async () => {
96
+ try {
97
+ const result = await applyDiff(filePath, diff, options, signal);
98
+ return { ...result, path: fileName };
99
+ }
100
+ catch (error) {
101
+ return {
102
+ path: fileName,
103
+ applied: false,
104
+ error: formatUnknownErrorMessage(error),
105
+ };
106
+ }
107
+ };
108
+ });
109
+ const { results } = await processInParallel(promises, (task) => task(), PARALLEL_CONCURRENCY, signal);
118
110
  const totals = results.reduce((acc, r) => {
119
111
  if (r.applied) {
120
112
  acc.applied++;
@@ -137,24 +129,28 @@ async function handleMultiFilePatch(basePath, parsed, options, signal) {
137
129
  });
138
130
  }
139
131
  async function handleApplyPatch(args, signal) {
140
- assertPatchHasHunks(args.patch);
132
+ if (!args.patch.trim()) {
133
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch content is empty.');
134
+ }
141
135
  const fuzzFactor = args.fuzzFactor ?? 0;
142
136
  const parsed = parsePatch(args.patch);
137
+ const hasHunks = parsed.some((p) => p.hunks.length > 0);
138
+ if (!hasHunks) {
139
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch must include unified hunk headers (e.g., @@ -1,2 +1,2 @@).');
140
+ }
143
141
  const options = {
144
142
  dryRun: args.dryRun,
145
143
  fuzzFactor,
146
144
  autoConvertLineEndings: args.autoConvertLineEndings,
147
145
  };
148
- // Multi-file patch: best-effort per file
149
146
  if (parsed.length > 1) {
150
- return handleMultiFilePatch(args.path, parsed, options, signal);
147
+ return processMultiFilePatch(args.path, parsed, options, signal);
151
148
  }
152
- // Single-file patch: delegate to shared helper, then assert success
153
149
  const diff = parsed[0];
154
150
  if (!diff) {
155
151
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'No patch content found.');
156
152
  }
157
- const result = await applyPatchToFile(args.path, diff, options, signal);
153
+ const result = await applyDiff(args.path, diff, options, signal);
158
154
  if (!result.applied) {
159
155
  throw new McpError(ErrorCode.E_INVALID_INPUT, result.error === 'Patch had no effect'
160
156
  ? 'Patch had no effect \u2014 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.'
@@ -196,7 +192,7 @@ export function registerApplyPatchTool(server, options = {}) {
196
192
  return `🛠 patch: ${name} • failed`;
197
193
  const added = sc.linesAdded ?? 0;
198
194
  const removed = sc.linesRemoved ?? 0;
199
- const dry = args.dryRun ? 'dry run — ' : '';
195
+ const dry = args.dryRun ? 'dry run ' : '';
200
196
  if (added > 0 || removed > 0)
201
197
  return `🛠 patch: ${name} • ${dry} +${added} -${removed}`;
202
198
  return `🛠 patch: ${name} • ${dry}no changes`;
@@ -2,6 +2,7 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { createReadStream } from 'node:fs';
5
+ import { pipeline } from 'node:stream/promises';
5
6
  import { PARALLEL_CONCURRENCY } from '../lib/constants.js';
6
7
  import { ErrorCode } from '../lib/errors.js';
7
8
  import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/core.js';
@@ -26,15 +27,9 @@ export const CALCULATE_HASH_TOOL = {
26
27
  taskSupport: 'optional',
27
28
  };
28
29
  async function hashFile(filePath, encoding, signal) {
29
- assertNotAborted(signal);
30
- const hashOp = createHash('sha256');
31
- const stream = createReadStream(filePath, { signal });
32
- for await (const chunk of stream) {
33
- hashOp.update(chunk);
34
- assertNotAborted(signal);
35
- }
36
- assertNotAborted(signal);
37
- return encoding === undefined ? hashOp.digest() : hashOp.digest(encoding);
30
+ const hasher = createHash('sha256');
31
+ await pipeline(createReadStream(filePath, { signal, highWaterMark: 64 * 1024 }), hasher, { signal });
32
+ return encoding ? hasher.digest(encoding) : hasher.digest();
38
33
  }
39
34
  function toStableRelativePath(root, entryPath) {
40
35
  const relativePath = path.relative(root, entryPath);
@@ -84,25 +79,27 @@ async function hashDirectory(dirPath, options = {}) {
84
79
  });
85
80
  }
86
81
  assertNotAborted(signal);
87
- // Phase 2: hash files concurrently with bounded pool.
88
82
  const concurrency = Math.min(PARALLEL_CONCURRENCY, 8);
89
83
  const entries = [];
90
84
  let filesHashed = 0;
91
85
  const totalFiles = filteredPaths.length;
92
- for (let i = 0; i < filteredPaths.length; i += concurrency) {
93
- assertNotAborted(signal);
94
- const batch = filteredPaths.slice(i, i + concurrency);
95
- const batchResults = await Promise.all(batch.map(async ({ filePath, relativePath }) => {
96
- const fileHash = await hashFile(filePath, undefined, signal);
97
- return { path: relativePath, hash: fileHash };
98
- }));
99
- entries.push(...batchResults);
100
- filesHashed += batchResults.length;
101
- reportPeriodicProgress(onProgress, filesHashed, {
102
- throttleModulo: 25,
103
- total: totalFiles,
104
- });
105
- }
86
+ const taskQueue = filteredPaths;
87
+ const worker = async () => {
88
+ while (taskQueue.length > 0) {
89
+ const task = taskQueue.pop();
90
+ if (!task)
91
+ break;
92
+ assertNotAborted(signal);
93
+ const fileHash = await hashFile(task.filePath, undefined, signal);
94
+ entries.push({ path: task.relativePath, hash: fileHash });
95
+ filesHashed++;
96
+ reportPeriodicProgress(onProgress, filesHashed, {
97
+ throttleModulo: 25,
98
+ total: totalFiles,
99
+ });
100
+ }
101
+ };
102
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
106
103
  reportPeriodicProgress(onProgress, filesHashed, {
107
104
  throttleModulo: 25,
108
105
  total: totalFiles,
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { createTwoFilesPatch } from 'diff';
3
+ import { formatPatch, structuredPatch } from 'diff';
4
4
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
5
  import { ErrorCode, McpError } from '../lib/errors.js';
6
6
  import { withAbort } from '../lib/fs-helpers.js';
@@ -20,19 +20,18 @@ export const DIFF_FILES_TOOL = {
20
20
  gotchas: ['`isIdentical=true` means no hunks (`@@`) and empty diff.'],
21
21
  taskSupport: 'forbidden',
22
22
  };
23
- function computeDiffStats(patch) {
23
+ function computeDiffStats(hunks) {
24
24
  let linesAdded = 0;
25
25
  let linesRemoved = 0;
26
- let hunksCount = 0;
27
- for (const line of patch.split('\n')) {
28
- if (line.startsWith('@@'))
29
- hunksCount++;
30
- else if (line.startsWith('+') && !line.startsWith('+++'))
31
- linesAdded++;
32
- else if (line.startsWith('-') && !line.startsWith('---'))
33
- linesRemoved++;
26
+ for (const hunk of hunks) {
27
+ for (const line of hunk.lines) {
28
+ if (line.startsWith('+') && !line.startsWith('+++'))
29
+ linesAdded++;
30
+ else if (line.startsWith('-') && !line.startsWith('---'))
31
+ linesRemoved++;
32
+ }
34
33
  }
35
- return { linesAdded, linesRemoved, hunksCount };
34
+ return { linesAdded, linesRemoved, hunksCount: hunks.length };
36
35
  }
37
36
  function assertDiffFileSizeWithinLimit(filePath, size, maxFileSize) {
38
37
  if (size <= maxFileSize)
@@ -55,14 +54,23 @@ async function handleDiffFiles(args, signal, resourceStore) {
55
54
  fs.readFile(originalPath, { encoding: 'utf-8', signal }),
56
55
  fs.readFile(modifiedPath, { encoding: 'utf-8', signal }),
57
56
  ]);
58
- const patch = createTwoFilesPatch(path.basename(originalPath), path.basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
59
- ...(args.context !== undefined ? { context: args.context } : {}),
60
- ignoreWhitespace: args.ignoreWhitespace,
61
- stripTrailingCr: args.stripTrailingCr,
57
+ const patchObj = await new Promise((resolve) => {
58
+ structuredPatch(path.basename(originalPath), path.basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
59
+ ...(args.context !== undefined ? { context: args.context } : {}),
60
+ ignoreWhitespace: args.ignoreWhitespace,
61
+ stripTrailingCr: args.stripTrailingCr,
62
+ timeout: 10000,
63
+ callback: (res) => {
64
+ resolve(res);
65
+ },
66
+ });
62
67
  });
63
- const isIdentical = !patch.includes('@@');
64
- const diffText = isIdentical ? '' : patch;
65
- const stats = isIdentical ? undefined : computeDiffStats(patch);
68
+ if (!patchObj) {
69
+ throw new McpError(ErrorCode.E_TIMEOUT, `Diff computation timed out or failed due to complexity.`, originalPath);
70
+ }
71
+ const isIdentical = patchObj.hunks.length === 0;
72
+ const diffText = isIdentical ? '' : formatPatch(patchObj);
73
+ const stats = isIdentical ? undefined : computeDiffStats(patchObj.hunks);
66
74
  const externalized = maybeExternalizeTextContent(resourceStore, diffText, {
67
75
  name: 'diff:patch',
68
76
  mimeType: 'text/x-diff',
@@ -3,5 +3,8 @@ import type { z } from 'zod';
3
3
  import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
4
4
  import { type ToolContract, type ToolRegistrationOptions, type ToolResponse } from './shared.js';
5
5
  export declare const EDIT_FILE_TOOL: ToolContract;
6
- export declare function handleEditFile(args: z.infer<typeof EditFileInputSchema>, signal?: AbortSignal): Promise<ToolResponse<z.infer<typeof EditFileOutputSchema>>>;
6
+ type EditInput = z.infer<typeof EditFileInputSchema>;
7
+ type EditOutput = z.infer<typeof EditFileOutputSchema>;
8
+ export declare function handleEditFile(args: EditInput, signal?: AbortSignal): Promise<ToolResponse<EditOutput>>;
7
9
  export declare function registerEditFileTool(server: McpServer, options?: ToolRegistrationOptions): void;
10
+ export {};