@j0hanz/filesystem-mcp 1.9.0 → 1.10.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 (48) hide show
  1. package/README.md +15 -15
  2. package/dist/completions.js +140 -115
  3. package/dist/lib/constants.d.ts +1 -0
  4. package/dist/lib/constants.js +2 -0
  5. package/dist/lib/file-operations/metadata.d.ts +5 -1
  6. package/dist/lib/file-operations/metadata.js +14 -1
  7. package/dist/lib/file-operations/search.d.ts +7 -5
  8. package/dist/lib/file-operations/search.js +64 -32
  9. package/dist/lib/fs-helpers.d.ts +3 -1
  10. package/dist/lib/fs-helpers.js +63 -0
  11. package/dist/lib/paths.d.ts +8 -0
  12. package/dist/lib/paths.js +119 -64
  13. package/dist/lib/resource-store.d.ts +2 -0
  14. package/dist/lib/resource-store.js +58 -17
  15. package/dist/prompts.d.ts +2 -0
  16. package/dist/prompts.js +51 -0
  17. package/dist/resources/generated-instructions.js +36 -9
  18. package/dist/resources/tool-catalog.js +30 -7
  19. package/dist/resources/tool-info.d.ts +4 -0
  20. package/dist/resources/tool-info.js +21 -3
  21. package/dist/resources/workflows.js +17 -5
  22. package/dist/schemas.d.ts +47 -12
  23. package/dist/schemas.js +75 -18
  24. package/dist/server/bootstrap.js +103 -91
  25. package/dist/server/roots-manager.d.ts +3 -0
  26. package/dist/server/roots-manager.js +15 -3
  27. package/dist/tools/apply-patch.js +135 -31
  28. package/dist/tools/calculate-hash.js +13 -8
  29. package/dist/tools/create-directory.js +14 -3
  30. package/dist/tools/delete-file.js +1 -0
  31. package/dist/tools/diff-files.js +26 -8
  32. package/dist/tools/edit-file.js +11 -8
  33. package/dist/tools/list-directory.js +1 -6
  34. package/dist/tools/move-file.js +39 -7
  35. package/dist/tools/read-multiple.js +9 -1
  36. package/dist/tools/read.js +38 -6
  37. package/dist/tools/replace-in-files.js +72 -25
  38. package/dist/tools/roots.js +1 -0
  39. package/dist/tools/search-content.js +76 -48
  40. package/dist/tools/search-files.js +6 -7
  41. package/dist/tools/shared.d.ts +2 -1
  42. package/dist/tools/shared.js +36 -20
  43. package/dist/tools/stat-many.js +1 -1
  44. package/dist/tools/stat.js +4 -0
  45. package/dist/tools/task-support.js +4 -12
  46. package/dist/tools/tree.js +4 -0
  47. package/dist/tools/write-file.js +4 -2
  48. package/package.json +17 -8
package/dist/schemas.js CHANGED
@@ -39,6 +39,7 @@ const TreeEntrySchema = z.lazy(() => z.strictObject({
39
39
  name: z.string().describe('Name'),
40
40
  type: FileTypeSchema.describe('Type'),
41
41
  relativePath: z.string().describe('Relative path'),
42
+ size: z.number().optional().describe('File size bytes (when includeSizes)'),
42
43
  children: z.array(TreeEntrySchema).optional().describe('Children'),
43
44
  }));
44
45
  const ErrorSchema = z.strictObject({
@@ -57,10 +58,13 @@ const HeadLinesSchema = z
57
58
  .max(100000, 'Max: 100,000')
58
59
  .optional()
59
60
  .describe('Read first N lines');
60
- const LineNumberSchema = z
61
- .number()
61
+ const TailLinesSchema = z
62
62
  .int({ error: 'Must be integer' })
63
- .min(1, 'Min: 1');
63
+ .min(1, 'Min: 1')
64
+ .max(100000, 'Max: 100,000')
65
+ .optional()
66
+ .describe('Read last N lines');
67
+ const LineNumberSchema = z.int({ error: 'Must be integer' }).min(1, 'Min: 1');
64
68
  function addReadRangeIssue(ctx, path, message) {
65
69
  ctx.addIssue({
66
70
  code: 'custom',
@@ -70,11 +74,15 @@ function addReadRangeIssue(ctx, path, message) {
70
74
  }
71
75
  const validateReadRange = (value, ctx) => {
72
76
  const hasHead = value.head !== undefined;
77
+ const hasTail = value.tail !== undefined;
73
78
  const hasStart = value.startLine !== undefined;
74
79
  const hasEnd = value.endLine !== undefined;
75
80
  if (hasHead && (hasStart || hasEnd)) {
76
81
  addReadRangeIssue(ctx, 'head', "Cannot use 'head' with 'startLine'/'endLine'");
77
82
  }
83
+ if (hasTail && (hasHead || hasStart || hasEnd)) {
84
+ addReadRangeIssue(ctx, 'tail', "Cannot use 'tail' with 'head'/'startLine'/'endLine'");
85
+ }
78
86
  if (hasEnd && !hasStart) {
79
87
  addReadRangeIssue(ctx, 'endLine', "'endLine' requires 'startLine'");
80
88
  }
@@ -87,6 +95,7 @@ const validateReadRange = (value, ctx) => {
87
95
  function createReadRangeInputFields(descriptions) {
88
96
  return {
89
97
  head: HeadLinesSchema.describe(descriptions.head),
98
+ tail: TailLinesSchema.describe(descriptions.tail),
90
99
  startLine: LineNumberSchema.optional().describe(descriptions.startLine),
91
100
  endLine: LineNumberSchema.optional().describe(descriptions.endLine),
92
101
  };
@@ -115,14 +124,12 @@ export const ListDirectoryInputSchema = z.strictObject({
115
124
  includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
116
125
  includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, .git, etc).'),
117
126
  maxDepth: z
118
- .number()
119
127
  .int({ error: 'Must be integer' })
120
128
  .min(1, 'Min: 1')
121
129
  .max(MAX_TREE_DEPTH, `Max: ${MAX_TREE_DEPTH}`)
122
130
  .optional()
123
131
  .describe('Max recursion depth when pattern is provided'),
124
132
  maxEntries: z
125
- .number()
126
133
  .int({ error: 'Must be integer' })
127
134
  .min(1, 'Min: 1')
128
135
  .max(MAX_LIST_ENTRIES, `Max: ${MAX_LIST_ENTRIES}`)
@@ -158,7 +165,6 @@ export const SearchFilesInputSchema = z.strictObject({
158
165
  })
159
166
  .describe('Glob pattern (e.g. "**/*.ts", "src/*.js")'),
160
167
  maxResults: z
161
- .number()
162
168
  .int({ error: 'Must be integer' })
163
169
  .min(1, 'Min: 1')
164
170
  .max(MAX_SEARCH_RESULTS, `Max: ${MAX_SEARCH_RESULTS}`)
@@ -171,7 +177,6 @@ export const SearchFilesInputSchema = z.strictObject({
171
177
  .default('path')
172
178
  .describe('Sort by path, name, size, or modified'),
173
179
  maxDepth: z
174
- .number()
175
180
  .int({ error: 'Must be integer' })
176
181
  .min(0, 'Min: 0')
177
182
  .max(MAX_SEARCH_DEPTH, `Max: ${MAX_SEARCH_DEPTH}`)
@@ -185,7 +190,6 @@ export const SearchFilesInputSchema = z.strictObject({
185
190
  export const TreeInputSchema = z.strictObject({
186
191
  path: OptionalPathSchema.describe(DESC_PATH_ROOT),
187
192
  maxDepth: z
188
- .number()
189
193
  .int({ error: 'Must be integer' })
190
194
  .min(0, 'Min: 0')
191
195
  .max(MAX_TREE_DEPTH, `Max: ${MAX_TREE_DEPTH}`)
@@ -193,7 +197,6 @@ export const TreeInputSchema = z.strictObject({
193
197
  .default(DEFAULT_TREE_DEPTH)
194
198
  .describe(`Depth (0=root node only, no children). Default: ${DEFAULT_TREE_DEPTH}`),
195
199
  maxEntries: z
196
- .number()
197
200
  .int({ error: 'Must be integer' })
198
201
  .min(1, 'Min: 1')
199
202
  .max(MAX_TREE_ENTRIES, `Max: ${MAX_TREE_ENTRIES}`)
@@ -202,6 +205,7 @@ export const TreeInputSchema = z.strictObject({
202
205
  .describe(`Max entries. Default: ${DEFAULT_TREE_ENTRIES}`),
203
206
  includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
204
207
  includeIgnored: defaultFalseBoolean('Include ignored items. Disables .gitignore.'),
208
+ includeSizes: defaultFalseBoolean('Include file sizes in tree entries'),
205
209
  });
206
210
  export const SearchContentInputSchema = z.strictObject({
207
211
  path: OptionalPathSchema.describe(DESC_PATH_ROOT),
@@ -214,7 +218,6 @@ export const SearchContentInputSchema = z.strictObject({
214
218
  caseSensitive: defaultFalseBoolean('Case-sensitive matching. Default: case-insensitive.'),
215
219
  wholeWord: defaultFalseBoolean('Match whole words only'),
216
220
  contextLines: z
217
- .number()
218
221
  .int({ error: 'Must be integer' })
219
222
  .min(0, 'Min: 0')
220
223
  .max(50, 'Max: 50')
@@ -222,7 +225,6 @@ export const SearchContentInputSchema = z.strictObject({
222
225
  .default(0)
223
226
  .describe('Include N lines of context before/after matches'),
224
227
  maxResults: z
225
- .number()
226
228
  .int({ error: 'Must be integer' })
227
229
  .min(0, 'Min: 0')
228
230
  .max(MAX_SEARCH_RESULTS, `Max: ${MAX_SEARCH_RESULTS}`)
@@ -238,15 +240,18 @@ export const SearchContentInputSchema = z.strictObject({
238
240
  .describe('Glob for candidate files (e.g. "**/*.ts")'),
239
241
  includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
240
242
  includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, etc).'),
243
+ multiline: defaultFalseBoolean('Multi-line mode. ^ and $ match line boundaries when isRegex=true.'),
241
244
  });
242
245
  export const ReadFileInputSchema = z
243
246
  .strictObject({
244
247
  path: RequiredPathSchema.describe(DESC_PATH_REQUIRED),
245
248
  ...createReadRangeInputFields({
246
249
  head: 'Read first N lines (preview)',
250
+ tail: 'Read last N lines',
247
251
  startLine: 'Start line (1-based, inclusive)',
248
252
  endLine: 'End line (1-based, inclusive). Requires startLine.',
249
253
  }),
254
+ includeHash: defaultFalseBoolean('Include SHA-256 hash of full file content'),
250
255
  })
251
256
  .superRefine(validateReadRange);
252
257
  export const ReadMultipleFilesInputSchema = z
@@ -258,6 +263,7 @@ export const ReadMultipleFilesInputSchema = z
258
263
  .describe('Files to read. e.g. ["src/index.ts"]'),
259
264
  ...createReadRangeInputFields({
260
265
  head: 'Read first N lines of each file',
266
+ tail: 'Read last N lines of each file',
261
267
  startLine: 'Start line (1-based, inclusive) per file',
262
268
  endLine: 'End line (1-based, inclusive) per file. Requires startLine.',
263
269
  }),
@@ -347,6 +353,10 @@ export const SearchContentOutputSchema = SearchSummarySchema.extend({
347
353
  .array(z.strictObject({
348
354
  file: z.string().describe('Relative path'),
349
355
  line: z.number(),
356
+ column: z
357
+ .number()
358
+ .optional()
359
+ .describe('Column of first match (0-based)'),
350
360
  content: z.string(),
351
361
  matchCount: z.number(),
352
362
  contextBefore: z.array(z.string()).optional(),
@@ -381,8 +391,12 @@ const ReadResultSchema = z.strictObject({
381
391
  truncated: z.boolean().optional().describe('Truncated?'),
382
392
  resourceUri: z.string().optional().describe('Full content URI'),
383
393
  totalLines: z.number().optional().describe('Total lines'),
384
- readMode: z.enum(['full', 'head', 'range']).optional().describe('Mode'),
394
+ readMode: z
395
+ .enum(['full', 'head', 'tail', 'range'])
396
+ .optional()
397
+ .describe('Mode'),
385
398
  head: z.number().optional().describe('Head lines'),
399
+ tail: z.number().optional().describe('Tail lines'),
386
400
  startLine: z.number().optional().describe('Start line'),
387
401
  endLine: z.number().optional().describe('End line'),
388
402
  linesRead: z.number().optional().describe('Lines read'),
@@ -391,12 +405,13 @@ const ReadResultSchema = z.strictObject({
391
405
  export const ReadFileOutputSchema = ReadResultSchema.extend({
392
406
  ok: z.boolean(),
393
407
  path: z.string().optional(),
408
+ contentHash: z.string().optional().describe('SHA-256 of full file content'),
394
409
  error: ErrorSchema.optional(),
395
410
  });
396
411
  const ReadMultipleFileResultSchema = ReadResultSchema.extend({
397
412
  path: z.string().describe('File path'),
398
413
  truncationReason: z
399
- .enum(['head', 'range', 'externalized'])
414
+ .enum(['head', 'tail', 'range', 'externalized'])
400
415
  .optional()
401
416
  .describe('Why content was truncated'),
402
417
  maxTotalSize: z.number().optional().describe('Max total size budget'),
@@ -434,7 +449,7 @@ export const CreateDirectoryInputSchema = z
434
449
  .describe('Absolute paths to directories to create'),
435
450
  })
436
451
  .refine((data) => data.path !== undefined || data.paths !== undefined, {
437
- message: "Either 'path' or 'paths' must be provided",
452
+ error: "Either 'path' or 'paths' must be provided",
438
453
  path: ['path'],
439
454
  });
440
455
  export const CreateDirectoryOutputSchema = z.strictObject({
@@ -481,6 +496,7 @@ export const EditFileOutputSchema = z.strictObject({
481
496
  .array(z.string())
482
497
  .optional()
483
498
  .describe('Edits that could not be applied'),
499
+ diff: z.string().optional().describe('Unified diff of changes (dryRun)'),
484
500
  error: ErrorSchema.optional(),
485
501
  });
486
502
  export const MoveFileInputSchema = z
@@ -490,7 +506,7 @@ export const MoveFileInputSchema = z
490
506
  destination: RequiredPathSchema.describe('New path'),
491
507
  })
492
508
  .refine((data) => (data.source ?? data.sources) !== undefined, {
493
- message: "Either 'source' or 'sources' must be provided",
509
+ error: "Either 'source' or 'sources' must be provided",
494
510
  path: ['source'],
495
511
  });
496
512
  export const MoveFileOutputSchema = z.strictObject({
@@ -535,7 +551,6 @@ export const DiffFilesInputSchema = z.strictObject({
535
551
  original: RequiredPathSchema.describe('Path to original file'),
536
552
  modified: RequiredPathSchema.describe('Path to modified file'),
537
553
  context: z
538
- .number()
539
554
  .int({ error: 'Must be integer' })
540
555
  .min(0, 'Min: 0')
541
556
  .max(10000, 'Max: 10,000')
@@ -556,6 +571,9 @@ export const DiffFilesOutputSchema = z.strictObject({
556
571
  ok: z.boolean(),
557
572
  diff: z.string().optional().describe('Unified diff content'),
558
573
  isIdentical: z.boolean().optional().describe('True if files are identical'),
574
+ linesAdded: z.number().optional().describe('Lines added'),
575
+ linesRemoved: z.number().optional().describe('Lines removed'),
576
+ hunksCount: z.number().optional().describe('Number of diff hunks'),
559
577
  truncated: z.boolean().optional().describe('Diff content truncated?'),
560
578
  resourceUri: z.string().optional().describe('Full diff content URI'),
561
579
  error: ErrorSchema.optional(),
@@ -564,9 +582,12 @@ export const ApplyPatchInputSchema = z.strictObject({
564
582
  path: RequiredPathSchema.describe('Path to file to patch'),
565
583
  patch: z
566
584
  .string()
585
+ .min(1, 'Patch content required')
586
+ .refine((val) => /@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/u.test(val), {
587
+ error: 'Patch must include hunk headers (e.g., @@ -1,2 +1,2 @@)',
588
+ })
567
589
  .describe('Unified diff with @@ hunk headers. Generate with `diff_files`.'),
568
590
  fuzzFactor: z
569
- .number()
570
591
  .int({ error: 'Must be integer' })
571
592
  .min(0, 'Min: 0')
572
593
  .max(20, 'Max: 20')
@@ -587,6 +608,20 @@ export const ApplyPatchOutputSchema = z.strictObject({
587
608
  ok: z.boolean(),
588
609
  path: z.string().optional(),
589
610
  applied: z.boolean().optional(),
611
+ hunksApplied: z.number().optional().describe('Hunks applied'),
612
+ linesAdded: z.number().optional().describe('Lines added'),
613
+ linesRemoved: z.number().optional().describe('Lines removed'),
614
+ results: z
615
+ .array(z.strictObject({
616
+ path: z.string().describe('File path'),
617
+ applied: z.boolean().describe('Patch applied successfully'),
618
+ hunksApplied: z.number().optional().describe('Hunks applied'),
619
+ linesAdded: z.number().optional().describe('Lines added'),
620
+ linesRemoved: z.number().optional().describe('Lines removed'),
621
+ error: z.string().optional().describe('Error message'),
622
+ }))
623
+ .optional()
624
+ .describe('Per-file results for multi-file patches'),
590
625
  error: ErrorSchema.optional(),
591
626
  });
592
627
  export const SearchAndReplaceInputSchema = z.strictObject({
@@ -595,16 +630,24 @@ export const SearchAndReplaceInputSchema = z.strictObject({
595
630
  .string()
596
631
  .min(1, 'Pattern required')
597
632
  .max(1000, 'Max 1000 chars')
633
+ .optional()
634
+ .default('**/*')
598
635
  .refine((val) => isSafeGlobPattern(val), {
599
636
  error: 'Invalid glob or unsafe path (absolute/.. forbidden)',
600
637
  })
601
- .describe('Glob pattern (e.g. "**/*.ts")'),
638
+ .describe('Glob to filter files. Default: **/*'),
602
639
  searchPattern: z
603
640
  .string()
604
641
  .min(1, 'Search pattern required')
642
+ .max(1000, 'Max 1000 chars')
605
643
  .describe('Text to search for. Literal by default; RE2 regex when `isRegex=true`.'),
606
644
  replacement: z.string().describe('Replacement text'),
607
645
  isRegex: defaultFalseBoolean('Treat searchPattern as RE2 regex. Supports capture groups ($1, $2) in replacement.'),
646
+ caseSensitive: z
647
+ .boolean()
648
+ .optional()
649
+ .default(true)
650
+ .describe('Case-sensitive matching. Default: true.'),
608
651
  dryRun: defaultFalseBoolean('Preview matches without writing. Check changedFiles and matches in the response before committing.'),
609
652
  includeHidden: z
610
653
  .boolean()
@@ -618,6 +661,12 @@ export const SearchAndReplaceInputSchema = z.strictObject({
618
661
  .boolean()
619
662
  .optional()
620
663
  .describe('Return unified diff of changes even if dryRun is false. Default: false.'),
664
+ maxFiles: z
665
+ .int({ error: 'Must be integer' })
666
+ .min(1, 'Min: 1')
667
+ .max(10000, 'Max: 10,000')
668
+ .optional()
669
+ .describe('Max files to process before stopping'),
621
670
  });
622
671
  export const SearchAndReplaceOutputSchema = z.strictObject({
623
672
  ok: z.boolean(),
@@ -644,6 +693,14 @@ export const SearchAndReplaceOutputSchema = z.strictObject({
644
693
  .optional()
645
694
  .describe('Changed file list truncated'),
646
695
  diff: z.string().optional().describe('Unified diff of changes (dryRun only)'),
696
+ diffTruncated: z
697
+ .boolean()
698
+ .optional()
699
+ .describe('Diff was truncated to fit size limit'),
700
+ stoppedReason: z
701
+ .enum(['maxFiles'])
702
+ .optional()
703
+ .describe('Why processing stopped early'),
647
704
  dryRun: z.boolean().optional(),
648
705
  error: ErrorSchema.optional(),
649
706
  });
@@ -6,13 +6,14 @@ 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
8
  import { isInitializeRequest, SetLevelRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
9
- import { DEFAULT_LOG_LEVEL, parseEnvInt, REQUIRED_MCP_PROTOCOL_VERSION, } from '../lib/constants.js';
9
+ import { DEFAULT_LOG_LEVEL, parseEnvInt } from '../lib/constants.js';
10
10
  import { formatUnknownErrorMessage } from '../lib/errors.js';
11
+ import { withAllowedDirectoriesState } from '../lib/paths.js';
11
12
  import { createInMemoryResourceStore } from '../lib/resource-store.js';
12
13
  import { isRecord } from '../lib/utils.js';
13
14
  import { registerCompletions } from '../completions.js';
14
15
  import { pkgInfo } from '../pkg-info.js';
15
- import { registerGetHelpPrompt } from '../prompts.js';
16
+ import { registerAnalyzePathPrompt, registerCompareFilesPrompt, registerGetHelpPrompt, } from '../prompts.js';
16
17
  import { registerInstructionResource, registerMetricsResource, registerResultResources, registerToolCatalogResource, registerToolInfoResource, registerWorkflowGuideResource, } from '../resources.js';
17
18
  import { buildServerInstructions } from '../resources/generated-instructions.js';
18
19
  import { registerAllTools } from '../tools.js';
@@ -51,9 +52,7 @@ export function buildServerCapabilities(options = {}) {
51
52
  if (options.enableTaskToolRequests) {
52
53
  // NOTE: enabling task tool requests requires the caller to configure
53
54
  // an InMemoryTaskStore and InMemoryTaskMessageQueue on the McpServer.
54
- // InMemoryTaskStore accumulates completed task records with no TTL eviction —
55
- // suitable for short-lived stdio sessions. Long-running HTTP servers should
56
- // replace it with a TTL-evicting store to avoid unbounded memory growth.
55
+ // InMemoryTaskStore auto-evicts tasks after TTL via setTimeout.
57
56
  capabilities.tasks = {
58
57
  list: {},
59
58
  cancel: {},
@@ -144,7 +143,7 @@ export async function createServer(options = {}) {
144
143
  }),
145
144
  };
146
145
  if (taskToolSupport) {
147
- // Enabling task tool support requires configuring a task store and message queue on the server config. We use in-memory implementations which are suitable for short-lived stdio sessions. Long-running HTTP servers should replace these with TTL-evicting implementations to avoid unbounded memory growth.
146
+ // Enabling task tool support requires configuring a task store and message queue on the server config. We use in-memory implementations from the SDK which auto-evict tasks after their TTL expires (via setTimeout). Suitable for both stdio and HTTP sessions.
148
147
  serverConfig.taskStore = new InMemoryTaskStore();
149
148
  serverConfig.taskMessageQueue = new InMemoryTaskMessageQueue();
150
149
  }
@@ -173,6 +172,8 @@ export async function createServer(options = {}) {
173
172
  registerWorkflowGuideResource(server, localIcon);
174
173
  registerToolInfoResource(server, localIcon);
175
174
  registerGetHelpPrompt(server, serverInstructions, localIcon);
175
+ registerCompareFilesPrompt(server, localIcon);
176
+ registerAnalyzePathPrompt(server, localIcon);
176
177
  registerResultResources(server, resourceStore, localIcon);
177
178
  registerMetricsResource(server, localIcon);
178
179
  registerCompletions(server, serverInstructions);
@@ -249,7 +250,11 @@ async function createHttpSession(options, sessions) {
249
250
  const transport = new StreamableHTTPServerTransport({
250
251
  sessionIdGenerator: () => randomUUID(),
251
252
  onsessioninitialized: (sessionId) => {
252
- sessions.set(sessionId, { server: mcpServer, transport });
253
+ sessions.set(sessionId, {
254
+ server: mcpServer,
255
+ rootsManager,
256
+ transport,
257
+ });
253
258
  rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
254
259
  },
255
260
  });
@@ -264,7 +269,7 @@ async function createHttpSession(options, sessions) {
264
269
  });
265
270
  };
266
271
  await mcpServer.connect(transport);
267
- return { server: mcpServer, transport };
272
+ return { server: mcpServer, rootsManager, transport };
268
273
  }
269
274
  function sendJsonRpcError(res, status, code, message) {
270
275
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -275,27 +280,55 @@ function sendJsonRpcError(res, status, code, message) {
275
280
  }));
276
281
  }
277
282
  const LOCALHOST_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/u;
283
+ const MAX_SESSION_ID_LENGTH = 256;
284
+ const MAX_BEARER_TOKEN_LENGTH = 4096;
285
+ const JSON_RPC_SERVER_ERROR = -32000;
286
+ const JSON_RPC_INVALID_REQUEST = -32600;
287
+ const JSON_RPC_PARSE_ERROR = -32700;
288
+ const JSON_RPC_INTERNAL_ERROR = -32603;
278
289
  function isAllowedOrigin(origin) {
279
290
  if (origin === undefined)
280
291
  return true; // Non-browser clients omit Origin.
281
292
  return LOCALHOST_ORIGIN_RE.test(origin);
282
293
  }
283
- function getProtocolVersionHeader(req) {
284
- const rawProtocolVersion = req.headers['mcp-protocol-version'];
285
- if (typeof rawProtocolVersion === 'string') {
286
- return rawProtocolVersion;
294
+ function getSessionId(req) {
295
+ const rawSessionId = req.headers['mcp-session-id'];
296
+ return typeof rawSessionId === 'string' &&
297
+ rawSessionId.length <= MAX_SESSION_ID_LENGTH
298
+ ? rawSessionId
299
+ : undefined;
300
+ }
301
+ function isAuthorizedBearer(apiKey, authHeader) {
302
+ const bearerPrefix = 'Bearer ';
303
+ if (typeof authHeader !== 'string' || !authHeader.startsWith(bearerPrefix)) {
304
+ return false;
287
305
  }
288
- if (Array.isArray(rawProtocolVersion)) {
289
- return rawProtocolVersion.find((value) => value === REQUIRED_MCP_PROTOCOL_VERSION);
306
+ const userKey = authHeader.slice(bearerPrefix.length);
307
+ if (userKey.length > MAX_BEARER_TOKEN_LENGTH) {
308
+ return false;
290
309
  }
291
- return undefined;
310
+ const expectedHash = createHash('sha256').update(apiKey).digest();
311
+ const actualHash = createHash('sha256').update(userKey).digest();
312
+ return timingSafeEqual(expectedHash, actualHash);
313
+ }
314
+ function writeUnauthorizedResponse(res) {
315
+ res.writeHead(401, {
316
+ 'Content-Type': 'application/json',
317
+ 'WWW-Authenticate': 'Bearer',
318
+ });
319
+ res.end(JSON.stringify({
320
+ jsonrpc: '2.0',
321
+ error: { code: JSON_RPC_SERVER_ERROR, message: 'Unauthorized' },
322
+ id: null,
323
+ }));
292
324
  }
293
- function ensureProtocolVersionHeader(req, res) {
294
- const protocolVersion = getProtocolVersionHeader(req);
295
- if (protocolVersion === REQUIRED_MCP_PROTOCOL_VERSION) {
325
+ function ensureAuthorizedRequest(req, res) {
326
+ const apiKey = process.env['FILESYSTEM_MCP_API_KEY'];
327
+ if (!apiKey)
296
328
  return true;
297
- }
298
- sendJsonRpcError(res, 400, -32000, 'Bad Request: MCP-Protocol-Version header missing or unsupported');
329
+ if (isAuthorizedBearer(apiKey, req.headers['authorization']))
330
+ return true;
331
+ writeUnauthorizedResponse(res);
299
332
  return false;
300
333
  }
301
334
  function discardRequestBody(req) {
@@ -304,103 +337,80 @@ function discardRequestBody(req) {
304
337
  });
305
338
  req.resume();
306
339
  }
340
+ async function handleSessionTransportRequest(session, req, res, body) {
341
+ await withAllowedDirectoriesState(session.rootsManager.getAllowedDirectoriesState(), () => session.transport.handleRequest(req, res, body));
342
+ }
343
+ function getSessionOrRespondNotFound(sessions, sessionId, res) {
344
+ const session = sessions.get(sessionId);
345
+ if (!session) {
346
+ sendJsonRpcError(res, 404, JSON_RPC_SERVER_ERROR, 'Session not found');
347
+ return undefined;
348
+ }
349
+ return session;
350
+ }
351
+ function isLoopbackHttpHost(host) {
352
+ const normalized = host.trim().toLowerCase();
353
+ return (normalized === '127.0.0.1' ||
354
+ normalized === 'localhost' ||
355
+ normalized === '::1' ||
356
+ normalized === '[::1]');
357
+ }
358
+ function assertHttpBindingSecurity(host) {
359
+ if (isLoopbackHttpHost(host))
360
+ return;
361
+ if (process.env['FILESYSTEM_MCP_API_KEY'])
362
+ return;
363
+ throw new Error(`Refusing to bind HTTP server to non-loopback host '${host}' without FILESYSTEM_MCP_API_KEY.`);
364
+ }
307
365
  export async function startHttpServer(port, options) {
308
366
  const sessions = new Map();
367
+ const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
368
+ assertHttpBindingSecurity(httpHost);
309
369
  async function handleMcpRequest(req, res) {
310
370
  const { method } = req;
311
- const MAX_SESSION_ID_LENGTH = 256;
312
- const rawSessionId = req.headers['mcp-session-id'];
313
- const sessionId = typeof rawSessionId === 'string' &&
314
- rawSessionId.length <= MAX_SESSION_ID_LENGTH
315
- ? rawSessionId
316
- : undefined;
371
+ const sessionId = getSessionId(req);
317
372
  const { origin } = req.headers;
318
373
  if (!isAllowedOrigin(origin)) {
319
- sendJsonRpcError(res, 403, -32000, 'Forbidden: disallowed origin');
374
+ sendJsonRpcError(res, 403, JSON_RPC_SERVER_ERROR, 'Forbidden: disallowed origin');
320
375
  return;
321
376
  }
322
- const apiKey = process.env['FILESYSTEM_MCP_API_KEY'];
323
- if (apiKey) {
324
- const authHeader = req.headers['authorization'];
325
- const bearerPrefix = 'Bearer ';
326
- let authorized = false;
327
- if (typeof authHeader === 'string' &&
328
- authHeader.startsWith(bearerPrefix)) {
329
- const userKey = authHeader.slice(bearerPrefix.length);
330
- if (userKey.length <= 4096) {
331
- const expectedHash = createHash('sha256').update(apiKey).digest();
332
- const actualHash = createHash('sha256').update(userKey).digest();
333
- authorized = timingSafeEqual(expectedHash, actualHash);
334
- }
335
- }
336
- if (!authorized) {
337
- res.writeHead(401, {
338
- 'Content-Type': 'application/json',
339
- 'WWW-Authenticate': 'Bearer',
340
- });
341
- res.end(JSON.stringify({
342
- jsonrpc: '2.0',
343
- error: { code: -32000, message: 'Unauthorized' },
344
- id: null,
345
- }));
346
- return;
347
- }
348
- }
377
+ if (!ensureAuthorizedRequest(req, res))
378
+ return;
349
379
  try {
350
380
  if (method === 'POST') {
351
381
  if (sessionId) {
352
- if (!sessions.has(sessionId)) {
353
- sendJsonRpcError(res, 404, -32000, 'Session not found');
354
- discardRequestBody(req);
355
- return;
356
- }
357
- if (!ensureProtocolVersionHeader(req, res)) {
382
+ const session = getSessionOrRespondNotFound(sessions, sessionId, res);
383
+ if (!session) {
358
384
  discardRequestBody(req);
359
385
  return;
360
386
  }
361
387
  const body = await readRequestBody(req);
362
- const session = sessions.get(sessionId);
363
- if (session) {
364
- await session.transport.handleRequest(req, res, body);
365
- }
366
- else {
367
- sendJsonRpcError(res, 404, -32000, 'Session not found');
368
- }
388
+ await handleSessionTransportRequest(session, req, res, body);
369
389
  return;
370
390
  }
371
391
  const body = await readRequestBody(req);
372
392
  if (isInitializeRequest(body)) {
373
393
  const maxSessions = parseEnvInt('FILESYSTEM_MCP_MAX_HTTP_SESSIONS', 100, 1, 10_000);
374
394
  if (sessions.size >= maxSessions) {
375
- sendJsonRpcError(res, 503, -32000, 'Too many sessions');
395
+ sendJsonRpcError(res, 503, JSON_RPC_SERVER_ERROR, 'Too many sessions');
376
396
  return;
377
397
  }
378
- const { transport } = await createHttpSession(options, sessions);
379
- await transport.handleRequest(req, res, body);
398
+ const session = await createHttpSession(options, sessions);
399
+ await handleSessionTransportRequest(session, req, res, body);
380
400
  return;
381
401
  }
382
- sendJsonRpcError(res, 400, -32000, 'Bad Request: No valid session ID provided');
402
+ sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: No valid session ID provided');
383
403
  discardRequestBody(req);
384
404
  }
385
405
  else if (method === 'GET' || method === 'DELETE') {
386
406
  if (!sessionId) {
387
- sendJsonRpcError(res, 400, -32000, 'Bad Request: Missing session ID');
407
+ sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: Missing session ID');
388
408
  return;
389
409
  }
390
- if (!sessions.has(sessionId)) {
391
- sendJsonRpcError(res, 404, -32000, 'Session not found');
410
+ const session = getSessionOrRespondNotFound(sessions, sessionId, res);
411
+ if (!session)
392
412
  return;
393
- }
394
- if (!ensureProtocolVersionHeader(req, res)) {
395
- return;
396
- }
397
- const session = sessions.get(sessionId);
398
- if (session) {
399
- await session.transport.handleRequest(req, res);
400
- }
401
- else {
402
- sendJsonRpcError(res, 404, -32000, 'Session not found');
403
- }
413
+ await handleSessionTransportRequest(session, req, res);
404
414
  }
405
415
  else {
406
416
  res.writeHead(405, {
@@ -409,21 +419,26 @@ export async function startHttpServer(port, options) {
409
419
  });
410
420
  res.end(JSON.stringify({
411
421
  jsonrpc: '2.0',
412
- error: { code: -32000, message: 'Method Not Allowed' },
422
+ error: {
423
+ code: JSON_RPC_SERVER_ERROR,
424
+ message: 'Method Not Allowed',
425
+ },
413
426
  id: null,
414
427
  }));
415
428
  }
416
429
  }
417
430
  catch (error) {
418
431
  if (error instanceof RequestBodyError && !res.headersSent) {
419
- const rpcCode = error.statusCode === 413 ? -32600 : -32700;
432
+ const rpcCode = error.statusCode === 413
433
+ ? JSON_RPC_INVALID_REQUEST
434
+ : JSON_RPC_PARSE_ERROR;
420
435
  res.setHeader('Connection', 'close');
421
436
  sendJsonRpcError(res, error.statusCode, rpcCode, error.message);
422
437
  return;
423
438
  }
424
439
  console.error('[HTTP] Error handling request:', formatUnknownErrorMessage(error));
425
440
  if (!res.headersSent) {
426
- sendJsonRpcError(res, 500, -32603, 'Internal Server Error');
441
+ sendJsonRpcError(res, 500, JSON_RPC_INTERNAL_ERROR, 'Internal Server Error');
427
442
  }
428
443
  }
429
444
  }
@@ -439,9 +454,6 @@ export async function startHttpServer(port, options) {
439
454
  res.end('Not Found');
440
455
  }
441
456
  });
442
- // Default to localhost-only binding to prevent DNS-rebinding and unintended
443
- // external exposure. Override with FILESYSTEM_MCP_HTTP_HOST for remote setups.
444
- const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
445
457
  return new Promise((resolve, reject) => {
446
458
  httpServer.once('error', reject);
447
459
  httpServer.listen(port, httpHost, () => {
@@ -1,9 +1,11 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type AllowedDirectoriesState } from '../lib/paths.js';
2
3
  import { type LoggingState } from './bootstrap.js';
3
4
  import type { ServerOptions } from './bootstrap.js';
4
5
  export declare class RootsManager {
5
6
  private rootsUpdateTimeout;
6
7
  private rootDirectories;
8
+ private allowedDirectoriesState;
7
9
  private clientInitialized;
8
10
  private updatingRoots;
9
11
  private pendingRootsUpdate;
@@ -12,6 +14,7 @@ export declare class RootsManager {
12
14
  constructor(options: ServerOptions, loggingState: LoggingState);
13
15
  isInitialized(): boolean;
14
16
  destroy(): void;
17
+ getAllowedDirectoriesState(): AllowedDirectoriesState;
15
18
  logMissingDirectoriesIfNeeded(server: McpServer): void;
16
19
  registerHandlers(server: McpServer): void;
17
20
  recomputeAllowedDirectories(): Promise<void>;