@j0hanz/filesystem-mcp 1.11.0 → 1.12.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.
Files changed (42) hide show
  1. package/dist/completions.js +25 -0
  2. package/dist/lib/constants.d.ts +2 -0
  3. package/dist/lib/constants.js +2 -0
  4. package/dist/lib/file-operations/metadata.js +71 -142
  5. package/dist/lib/file-operations/search.js +119 -19
  6. package/dist/lib/file-operations/traversal.js +45 -102
  7. package/dist/lib/fs-helpers.js +175 -158
  8. package/dist/prompts.d.ts +1 -0
  9. package/dist/prompts.js +58 -0
  10. package/dist/resources/generated-instructions.js +11 -3
  11. package/dist/resources/tool-catalog.js +49 -3
  12. package/dist/resources/tool-info.d.ts +1 -0
  13. package/dist/resources/tool-info.js +139 -8
  14. package/dist/resources.js +2 -0
  15. package/dist/schemas.d.ts +2 -0
  16. package/dist/schemas.js +10 -4
  17. package/dist/server/bootstrap.js +41 -5
  18. package/dist/tools/apply-patch.js +30 -33
  19. package/dist/tools/calculate-hash.js +21 -24
  20. package/dist/tools/contract.d.ts +1 -1
  21. package/dist/tools/create-directory.js +3 -3
  22. package/dist/tools/delete-file.js +2 -2
  23. package/dist/tools/diff-files.js +32 -20
  24. package/dist/tools/edit-file.d.ts +4 -1
  25. package/dist/tools/edit-file.js +172 -104
  26. package/dist/tools/list-directory.js +105 -10
  27. package/dist/tools/move-file.js +3 -3
  28. package/dist/tools/read-multiple.d.ts +1 -1
  29. package/dist/tools/read-multiple.js +123 -99
  30. package/dist/tools/read.js +104 -76
  31. package/dist/tools/replace-in-files.d.ts +5 -2
  32. package/dist/tools/replace-in-files.js +142 -76
  33. package/dist/tools/roots.js +1 -1
  34. package/dist/tools/search-content.js +249 -218
  35. package/dist/tools/shared.js +39 -7
  36. package/dist/tools/stat-many.js +12 -19
  37. package/dist/tools/stat.js +1 -1
  38. package/dist/tools/task-support.d.ts +7 -0
  39. package/dist/tools/task-support.js +140 -101
  40. package/dist/tools/write-file.js +4 -4
  41. package/dist/tools.js +2 -2
  42. package/package.json +1 -1
@@ -1,5 +1,16 @@
1
+ import { z } from 'zod';
1
2
  import { DEFAULT_SEARCH_CONTENT_RESULTS, MAX_SEARCH_RESULTS, MAX_TEXT_FILE_SIZE, } from '../lib/constants.js';
2
3
  import { ALL_TOOLS } from '../tools.js';
4
+ function getTaskSupportLabel(taskSupport) {
5
+ switch (taskSupport) {
6
+ case 'optional':
7
+ return '[Task: Optional]';
8
+ case 'required':
9
+ return '[Task: Required]';
10
+ default:
11
+ return undefined;
12
+ }
13
+ }
3
14
  function toEntry(contract) {
4
15
  const annotations = [];
5
16
  if (contract.annotations?.destructiveHint)
@@ -8,11 +19,12 @@ function toEntry(contract) {
8
19
  annotations.push('[Idempotent]');
9
20
  if (contract.annotations?.readOnlyHint)
10
21
  annotations.push('[Read-Only]');
11
- if (contract.taskSupport === 'optional' ||
12
- contract.taskSupport === 'required')
13
- annotations.push('[Task]');
22
+ const taskLabel = getTaskSupportLabel(contract.taskSupport);
23
+ if (taskLabel)
24
+ annotations.push(taskLabel);
14
25
  return {
15
26
  name: contract.name,
27
+ title: contract.title,
16
28
  description: contract.description,
17
29
  ...(annotations.length > 0 ? { annotations } : {}),
18
30
  ...(contract.nuances && contract.nuances.length > 0
@@ -43,6 +55,11 @@ export function getTaskCapableToolNames() {
43
55
  contract.taskSupport === 'required')
44
56
  .map((contract) => contract.name);
45
57
  }
58
+ export function getTaskToolNamesBySupport(taskSupport) {
59
+ return getSortedToolContracts()
60
+ .filter((contract) => contract.taskSupport === taskSupport)
61
+ .map((contract) => contract.name);
62
+ }
46
63
  export function buildCoreContextPack() {
47
64
  const rows = getSortedToolContracts().map((contract) => {
48
65
  const e = ENTRIES[contract.name];
@@ -61,25 +78,139 @@ export function getSharedConstraints() {
61
78
  'If response includes `resourceUri`, call `resources/read` immediately — cached results expire on restart.',
62
79
  ];
63
80
  }
81
+ function formatTaskSupportLabel(taskSupport) {
82
+ switch (taskSupport) {
83
+ case 'optional':
84
+ return 'optional';
85
+ case 'required':
86
+ return 'required';
87
+ default:
88
+ return 'forbidden';
89
+ }
90
+ }
91
+ function formatAnnotationValue(value) {
92
+ return value ? 'true' : 'false';
93
+ }
94
+ function toJsonSchemaObject(schema, io = 'output') {
95
+ return z.toJSONSchema(schema, { io });
96
+ }
97
+ function summarizeSchemaType(schema) {
98
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
99
+ return `enum(${schema.enum.map((value) => JSON.stringify(value)).join(', ')})`;
100
+ }
101
+ if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
102
+ return schema.anyOf.map(summarizeSchemaType).join(' | ');
103
+ }
104
+ if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
105
+ return schema.oneOf.map(summarizeSchemaType).join(' | ');
106
+ }
107
+ if (schema.type === 'array') {
108
+ const itemType = schema.items
109
+ ? summarizeSchemaType(schema.items)
110
+ : 'unknown';
111
+ return `array<${itemType}>`;
112
+ }
113
+ if (typeof schema.type === 'string' && schema.type.length > 0) {
114
+ return schema.type;
115
+ }
116
+ return 'unknown';
117
+ }
118
+ function buildSchemaFieldLines(label, schema) {
119
+ if (!schema) {
120
+ return [`<${label}>`, '- none', `</${label}>`];
121
+ }
122
+ const io = label === 'input_fields' ? 'input' : 'output';
123
+ const jsonSchema = toJsonSchemaObject(schema, io);
124
+ const properties = jsonSchema.properties ?? {};
125
+ const required = new Set(jsonSchema.required ?? []);
126
+ const fieldNames = Object.keys(properties);
127
+ const lines = [`<${label}>`];
128
+ if (typeof jsonSchema.description === 'string' &&
129
+ jsonSchema.description.length > 0) {
130
+ lines.push(`- Schema constraints: ${jsonSchema.description}`);
131
+ }
132
+ if (fieldNames.length === 0) {
133
+ lines.push('- object with no fields', `</${label}>`);
134
+ return lines;
135
+ }
136
+ lines.push(...fieldNames.map((fieldName) => {
137
+ const fieldSchema = properties[fieldName] ?? {};
138
+ const type = summarizeSchemaType(fieldSchema);
139
+ const requiredLabel = required.has(fieldName) ? 'required' : 'optional';
140
+ const description = typeof fieldSchema.description === 'string' &&
141
+ fieldSchema.description.length > 0
142
+ ? fieldSchema.description
143
+ : 'No description.';
144
+ return `- ${fieldName} (${type}, ${requiredLabel}): ${description}`;
145
+ }), `</${label}>`);
146
+ return lines;
147
+ }
148
+ function buildProtocolNotes(contract) {
149
+ const notes = [
150
+ '- Protocol failures use JSON-RPC `error`; execution failures use tool result `isError: true`.',
151
+ ];
152
+ if (contract.outputSchema) {
153
+ notes.push('- Successful responses include `structuredContent` that must match the declared output schema.');
154
+ }
155
+ if (contract.taskSupport === 'optional') {
156
+ notes.push('- Supports inline execution by default and task mode when durable polling or deferred results are needed.');
157
+ }
158
+ if (contract.taskSupport === 'required') {
159
+ notes.push('- Must run in task mode; callers should poll `tasks/get` and fetch the payload via `tasks/result`.');
160
+ }
161
+ if (contract.taskSupport === 'forbidden') {
162
+ notes.push('- Runs inline only; task augmentation is not supported for this tool.');
163
+ }
164
+ return notes;
165
+ }
64
166
  export function buildToolInfo(name) {
167
+ const contract = CONTRACTS_BY_NAME.get(name);
65
168
  const entry = ENTRIES[name];
66
- if (!entry)
169
+ if (!entry || !contract)
67
170
  return undefined;
68
- const lines = [`## ${entry.name}`, '', entry.description];
171
+ const lines = [
172
+ `<tool_info name="${entry.name}">`,
173
+ `## ${entry.name}`,
174
+ '',
175
+ `Title: ${entry.title}`,
176
+ `Description: ${entry.description}`,
177
+ '',
178
+ '<execution>',
179
+ `- taskSupport: ${formatTaskSupportLabel(contract.taskSupport)}`,
180
+ '</execution>',
181
+ '',
182
+ '<annotations>',
183
+ `- readOnlyHint: ${formatAnnotationValue(contract.annotations?.readOnlyHint)}`,
184
+ `- idempotentHint: ${formatAnnotationValue(contract.annotations?.idempotentHint)}`,
185
+ `- destructiveHint: ${formatAnnotationValue(contract.annotations?.destructiveHint)}`,
186
+ `- openWorldHint: ${formatAnnotationValue(contract.annotations?.openWorldHint)}`,
187
+ '</annotations>',
188
+ '',
189
+ ...buildSchemaFieldLines('input_fields', contract.inputSchema),
190
+ '',
191
+ ...buildSchemaFieldLines('output_fields', contract.outputSchema),
192
+ '',
193
+ '<protocol_notes>',
194
+ ...buildProtocolNotes(contract),
195
+ '</protocol_notes>',
196
+ ];
69
197
  if (entry.annotations && entry.annotations.length > 0) {
70
- lines.push('', `**Hints:** ${entry.annotations.join(', ')}`);
198
+ lines.push('', `<quick_hints>${entry.annotations.join(' ')}</quick_hints>`);
71
199
  }
72
200
  if (entry.nuances && entry.nuances.length > 0) {
73
- lines.push('', '**Nuances:**');
201
+ lines.push('', '<nuances>');
74
202
  for (const nuance of entry.nuances) {
75
203
  lines.push(`- ${nuance}`);
76
204
  }
205
+ lines.push('</nuances>');
77
206
  }
78
207
  if (entry.gotchas && entry.gotchas.length > 0) {
79
- lines.push('', '**Gotchas:**');
208
+ lines.push('', '<gotchas>');
80
209
  for (const gotcha of entry.gotchas) {
81
210
  lines.push(`- ${gotcha}`);
82
211
  }
212
+ lines.push('</gotchas>');
83
213
  }
214
+ lines.push('</tool_info>');
84
215
  return lines.join('\n');
85
216
  }
package/dist/resources.js CHANGED
@@ -13,6 +13,8 @@ const TOOL_INFO_TEMPLATE = new ResourceTemplate('internal://tool-info/{name}', {
13
13
  resources: getToolContracts().map((contract) => ({
14
14
  uri: `internal://tool-info/${contract.name}`,
15
15
  name: contract.name,
16
+ title: contract.title,
17
+ description: contract.description,
16
18
  mimeType: 'text/markdown',
17
19
  })),
18
20
  }),
package/dist/schemas.d.ts CHANGED
@@ -556,6 +556,8 @@ export declare const EditFileOutputSchema: z.ZodObject<{
556
556
  ok: z.ZodBoolean;
557
557
  path: z.ZodOptional<z.ZodString>;
558
558
  appliedEdits: z.ZodOptional<z.ZodNumber>;
559
+ linesAdded: z.ZodOptional<z.ZodNumber>;
560
+ linesRemoved: z.ZodOptional<z.ZodNumber>;
559
561
  lineRange: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
560
562
  unmatchedEdits: z.ZodOptional<z.ZodArray<z.ZodString>>;
561
563
  diff: z.ZodOptional<z.ZodString>;
package/dist/schemas.js CHANGED
@@ -253,7 +253,8 @@ export const ReadFileInputSchema = z
253
253
  }),
254
254
  includeHash: defaultFalseBoolean('Include SHA-256 hash of full file content'),
255
255
  })
256
- .superRefine(validateReadRange);
256
+ .superRefine(validateReadRange)
257
+ .describe("Use one read mode only: 'head', 'tail', or 'startLine'/'endLine'. 'endLine' requires 'startLine'.");
257
258
  export const ReadMultipleFilesInputSchema = z
258
259
  .strictObject({
259
260
  paths: z
@@ -268,7 +269,8 @@ export const ReadMultipleFilesInputSchema = z
268
269
  endLine: 'End line (1-based, inclusive) per file. Requires startLine.',
269
270
  }),
270
271
  })
271
- .superRefine(validateReadRange);
272
+ .superRefine(validateReadRange)
273
+ .describe("Use one read mode only: 'head', 'tail', or 'startLine'/'endLine'. 'endLine' requires 'startLine'.");
272
274
  export const GetFileInfoInputSchema = z.strictObject({
273
275
  path: RequiredPathSchema.describe(DESC_PATH_REQUIRED),
274
276
  });
@@ -451,7 +453,8 @@ export const CreateDirectoryInputSchema = z
451
453
  .refine((data) => data.path !== undefined || data.paths !== undefined, {
452
454
  error: "Either 'path' or 'paths' must be provided",
453
455
  path: ['path'],
454
- });
456
+ })
457
+ .describe("Provide either 'path' or 'paths'.");
455
458
  export const CreateDirectoryOutputSchema = z.strictObject({
456
459
  ok: z.boolean(),
457
460
  path: z.string().optional(),
@@ -488,6 +491,8 @@ export const EditFileOutputSchema = z.strictObject({
488
491
  ok: z.boolean(),
489
492
  path: z.string().optional(),
490
493
  appliedEdits: z.number().optional(),
494
+ linesAdded: z.number().optional().describe('Lines added'),
495
+ linesRemoved: z.number().optional().describe('Lines removed'),
491
496
  lineRange: z
492
497
  .tuple([z.number(), z.number()])
493
498
  .optional()
@@ -508,7 +513,8 @@ export const MoveFileInputSchema = z
508
513
  .refine((data) => (data.source ?? data.sources) !== undefined, {
509
514
  error: "Either 'source' or 'sources' must be provided",
510
515
  path: ['source'],
511
- });
516
+ })
517
+ .describe("Provide either 'source' or 'sources'.");
512
518
  export const MoveFileOutputSchema = z.strictObject({
513
519
  ok: z.boolean(),
514
520
  source: z.string().optional(),
@@ -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';
@@ -13,7 +13,7 @@ import { createInMemoryResourceStore } from '../lib/resource-store.js';
13
13
  import { isRecord } from '../lib/utils.js';
14
14
  import { registerCompletions } from '../completions.js';
15
15
  import { pkgInfo } from '../pkg-info.js';
16
- import { registerAnalyzePathPrompt, registerCompareFilesPrompt, registerGetHelpPrompt, } from '../prompts.js';
16
+ import { registerAnalyzePathPrompt, registerCompareFilesPrompt, registerGetHelpPrompt, registerGetToolHelpPrompt, } from '../prompts.js';
17
17
  import { registerInstructionResource, registerMetricsResource, registerResultResources, registerToolCatalogResource, registerToolInfoResource, registerWorkflowGuideResource, } from '../resources.js';
18
18
  import { buildServerInstructions } from '../resources/generated-instructions.js';
19
19
  import { registerAllTools } from '../tools.js';
@@ -174,6 +174,7 @@ export async function createServer(options = {}) {
174
174
  registerGetHelpPrompt(server, serverInstructions, localIcon);
175
175
  registerCompareFilesPrompt(server, localIcon);
176
176
  registerAnalyzePathPrompt(server, localIcon);
177
+ registerGetToolHelpPrompt(server, localIcon);
177
178
  registerResultResources(server, resourceStore, localIcon);
178
179
  registerMetricsResource(server, localIcon);
179
180
  registerCompletions(server, serverInstructions);
@@ -242,7 +243,7 @@ async function readRequestBody(req) {
242
243
  req.on('error', reject);
243
244
  });
244
245
  }
245
- async function createHttpSession(options, sessions) {
246
+ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
246
247
  const mcpServer = await createServer(options);
247
248
  const rootsManager = getRootsManager(mcpServer);
248
249
  rootsManager.registerHandlers(mcpServer);
@@ -254,6 +255,7 @@ async function createHttpSession(options, sessions) {
254
255
  server: mcpServer,
255
256
  rootsManager,
256
257
  transport,
258
+ negotiatedProtocolVersion,
257
259
  });
258
260
  rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
259
261
  },
@@ -269,7 +271,12 @@ async function createHttpSession(options, sessions) {
269
271
  });
270
272
  };
271
273
  await mcpServer.connect(transport);
272
- return { server: mcpServer, rootsManager, transport };
274
+ return {
275
+ server: mcpServer,
276
+ rootsManager,
277
+ transport,
278
+ negotiatedProtocolVersion,
279
+ };
273
280
  }
274
281
  function sendJsonRpcError(res, status, code, message) {
275
282
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -298,6 +305,29 @@ function getSessionId(req) {
298
305
  ? rawSessionId
299
306
  : undefined;
300
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
+ }
301
331
  function isAuthorizedBearer(apiKey, authHeader) {
302
332
  const bearerPrefix = 'Bearer ';
303
333
  if (typeof authHeader !== 'string' || !authHeader.startsWith(bearerPrefix)) {
@@ -384,6 +414,10 @@ export async function startHttpServer(port, options) {
384
414
  discardRequestBody(req);
385
415
  return;
386
416
  }
417
+ if (!ensureSessionProtocolVersion(req, res, session)) {
418
+ discardRequestBody(req);
419
+ return;
420
+ }
387
421
  const body = await readRequestBody(req);
388
422
  await handleSessionTransportRequest(session, req, res, body);
389
423
  return;
@@ -395,7 +429,7 @@ export async function startHttpServer(port, options) {
395
429
  sendJsonRpcError(res, 503, JSON_RPC_SERVER_ERROR, 'Too many sessions');
396
430
  return;
397
431
  }
398
- const session = await createHttpSession(options, sessions);
432
+ const session = await createHttpSession(options, sessions, resolveNegotiatedProtocolVersion(body.params.protocolVersion));
399
433
  await handleSessionTransportRequest(session, req, res, body);
400
434
  return;
401
435
  }
@@ -410,6 +444,8 @@ export async function startHttpServer(port, options) {
410
444
  const session = getSessionOrRespondNotFound(sessions, sessionId, res);
411
445
  if (!session)
412
446
  return;
447
+ if (!ensureSessionProtocolVersion(req, res, session))
448
+ return;
413
449
  await handleSessionTransportRequest(session, req, res);
414
450
  }
415
451
  else {
@@ -1,5 +1,5 @@
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
4
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
5
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
@@ -22,22 +22,13 @@ export const APPLY_PATCH_TOOL = {
22
22
  'Multi-file patches use `path` as base directory; per-file results in `results[]`.',
23
23
  ],
24
24
  gotchas: ['Patch must include valid hunk headers; use `dryRun=true` first.'],
25
- taskSupport: 'optional',
25
+ taskSupport: 'forbidden',
26
26
  };
27
27
  function assertPatchTargetSizeWithinLimit(filePath, size, maxFileSize) {
28
28
  if (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,31 @@ 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(async (diff) => {
96
86
  const fileName = extractPatchTargetPath(diff);
97
87
  if (!fileName) {
98
- results.push({
88
+ return {
99
89
  path: '<unknown>',
100
90
  applied: false,
101
91
  error: 'Missing file name in patch header',
102
- });
103
- continue;
92
+ };
104
93
  }
105
94
  const filePath = path.resolve(validBase, fileName);
106
95
  try {
107
- const result = await applyPatchToFile(filePath, diff, options, signal);
108
- results.push({ ...result, path: fileName });
96
+ const result = await applyDiff(filePath, diff, options, signal);
97
+ return { ...result, path: fileName };
109
98
  }
110
99
  catch (error) {
111
- results.push({
100
+ return {
112
101
  path: fileName,
113
102
  applied: false,
114
103
  error: formatUnknownErrorMessage(error),
115
- });
104
+ };
116
105
  }
117
- }
106
+ });
107
+ const results = await Promise.all(promises);
118
108
  const totals = results.reduce((acc, r) => {
119
109
  if (r.applied) {
120
110
  acc.applied++;
@@ -137,24 +127,28 @@ async function handleMultiFilePatch(basePath, parsed, options, signal) {
137
127
  });
138
128
  }
139
129
  async function handleApplyPatch(args, signal) {
140
- assertPatchHasHunks(args.patch);
130
+ if (!args.patch.trim()) {
131
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch content is empty.');
132
+ }
141
133
  const fuzzFactor = args.fuzzFactor ?? 0;
142
134
  const parsed = parsePatch(args.patch);
135
+ const hasHunks = parsed.some((p) => p.hunks.length > 0);
136
+ if (!hasHunks) {
137
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch must include unified hunk headers (e.g., @@ -1,2 +1,2 @@).');
138
+ }
143
139
  const options = {
144
140
  dryRun: args.dryRun,
145
141
  fuzzFactor,
146
142
  autoConvertLineEndings: args.autoConvertLineEndings,
147
143
  };
148
- // Multi-file patch: best-effort per file
149
144
  if (parsed.length > 1) {
150
- return handleMultiFilePatch(args.path, parsed, options, signal);
145
+ return processMultiFilePatch(args.path, parsed, options, signal);
151
146
  }
152
- // Single-file patch: delegate to shared helper, then assert success
153
147
  const diff = parsed[0];
154
148
  if (!diff) {
155
149
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'No patch content found.');
156
150
  }
157
- const result = await applyPatchToFile(args.path, diff, options, signal);
151
+ const result = await applyDiff(args.path, diff, options, signal);
158
152
  if (!result.applied) {
159
153
  throw new McpError(ErrorCode.E_INVALID_INPUT, result.error === 'Patch had no effect'
160
154
  ? '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.'
@@ -194,9 +188,12 @@ export function registerApplyPatchTool(server, options = {}) {
194
188
  const sc = result.structuredContent;
195
189
  if (!sc.ok)
196
190
  return `🛠 patch: ${name} • failed`;
197
- if (args.dryRun)
198
- return `🛠 patch: ${name} • dry run OK`;
199
- return `🛠 patch: ${name} • applied`;
191
+ const added = sc.linesAdded ?? 0;
192
+ const removed = sc.linesRemoved ?? 0;
193
+ const dry = args.dryRun ? 'dry run ' : '';
194
+ if (added > 0 || removed > 0)
195
+ return `🛠 patch: ${name} • ${dry} +${added} -${removed}`;
196
+ return `🛠 patch: ${name} • ${dry}no changes`;
200
197
  },
201
198
  });
202
199
  const validatedHandler = withValidatedArgs(ApplyPatchInputSchema, wrappedHandler);
@@ -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,
@@ -39,7 +39,7 @@ export interface ToolContract {
39
39
  */
40
40
  gotchas?: string[];
41
41
  /**
42
- * Task support level for the tool. Defaults to 'optional'.
42
+ * Task support level for the tool. Defaults to 'forbidden'.
43
43
  */
44
44
  taskSupport?: 'optional' | 'required' | 'forbidden';
45
45
  }
@@ -14,7 +14,7 @@ export const CREATE_DIRECTORY_TOOL = {
14
14
  outputSchema: CreateDirectoryOutputSchema,
15
15
  annotations: IDEMPOTENT_WRITE_TOOL_ANNOTATIONS,
16
16
  nuances: ['Succeeds silently if the directory already exists (idempotent).'],
17
- taskSupport: 'optional',
17
+ taskSupport: 'forbidden',
18
18
  };
19
19
  export async function handleCreateDirectory(args, signal) {
20
20
  const allPaths = [];
@@ -55,12 +55,12 @@ export function registerCreateDirectoryTool(server, options = {}) {
55
55
  const name = path.basename(args.path);
56
56
  if (result.isError)
57
57
  return `🛠 mkdir: ${name} • failed`;
58
- return `🛠 mkdir: ${name} • created`;
58
+ return `🛠 mkdir: ${name}`;
59
59
  }
60
60
  const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
61
61
  if (result.isError)
62
62
  return `🛠 mkdir: ${count} directories • failed`;
63
- return `🛠 mkdir: ${count} directories • created`;
63
+ return `🛠 mkdir: ${count} directories`;
64
64
  },
65
65
  });
66
66
  const validatedHandler = withValidatedArgs(CreateDirectoryInputSchema, wrappedHandler);
@@ -17,7 +17,7 @@ export const DELETE_FILE_TOOL = {
17
17
  'No undo — deletion is permanent.',
18
18
  'Non-empty directories require `recursive=true`.',
19
19
  ],
20
- taskSupport: 'optional',
20
+ taskSupport: 'forbidden',
21
21
  };
22
22
  async function handleDeleteFile(args, signal) {
23
23
  const validPath = await validatePathForWrite(args.path, signal);
@@ -90,7 +90,7 @@ export function registerDeleteFileTool(server, options = {}) {
90
90
  const name = path.basename(args.path);
91
91
  if (result.isError)
92
92
  return `🛠 rm: ${name} • failed`;
93
- return `🛠 rm: ${name} • deleted`;
93
+ return `🛠 rm: ${name}`;
94
94
  },
95
95
  });
96
96
  const validatedHandler = withValidatedArgs(DeleteFileInputSchema, wrappedHandler);