@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.
- package/README.md +15 -15
- package/dist/completions.js +140 -115
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/file-operations/metadata.d.ts +5 -1
- package/dist/lib/file-operations/metadata.js +14 -1
- package/dist/lib/file-operations/search.d.ts +7 -5
- package/dist/lib/file-operations/search.js +64 -32
- package/dist/lib/fs-helpers.d.ts +3 -1
- package/dist/lib/fs-helpers.js +63 -0
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +119 -64
- package/dist/lib/resource-store.d.ts +2 -0
- package/dist/lib/resource-store.js +58 -17
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +51 -0
- package/dist/resources/generated-instructions.js +36 -9
- package/dist/resources/tool-catalog.js +30 -7
- package/dist/resources/tool-info.d.ts +4 -0
- package/dist/resources/tool-info.js +21 -3
- package/dist/resources/workflows.js +17 -5
- package/dist/schemas.d.ts +47 -12
- package/dist/schemas.js +75 -18
- package/dist/server/bootstrap.js +103 -91
- package/dist/server/roots-manager.d.ts +3 -0
- package/dist/server/roots-manager.js +15 -3
- package/dist/tools/apply-patch.js +135 -31
- package/dist/tools/calculate-hash.js +13 -8
- package/dist/tools/create-directory.js +14 -3
- package/dist/tools/delete-file.js +1 -0
- package/dist/tools/diff-files.js +26 -8
- package/dist/tools/edit-file.js +11 -8
- package/dist/tools/list-directory.js +1 -6
- package/dist/tools/move-file.js +39 -7
- package/dist/tools/read-multiple.js +9 -1
- package/dist/tools/read.js +38 -6
- package/dist/tools/replace-in-files.js +72 -25
- package/dist/tools/roots.js +1 -0
- package/dist/tools/search-content.js +76 -48
- package/dist/tools/search-files.js +6 -7
- package/dist/tools/shared.d.ts +2 -1
- package/dist/tools/shared.js +36 -20
- package/dist/tools/stat-many.js +1 -1
- package/dist/tools/stat.js +4 -0
- package/dist/tools/task-support.js +4 -12
- package/dist/tools/tree.js +4 -0
- package/dist/tools/write-file.js +4 -2
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
306
|
+
const userKey = authHeader.slice(bearerPrefix.length);
|
|
307
|
+
if (userKey.length > MAX_BEARER_TOKEN_LENGTH) {
|
|
308
|
+
return false;
|
|
290
309
|
}
|
|
291
|
-
|
|
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
|
|
294
|
-
const
|
|
295
|
-
if (
|
|
325
|
+
function ensureAuthorizedRequest(req, res) {
|
|
326
|
+
const apiKey = process.env['FILESYSTEM_MCP_API_KEY'];
|
|
327
|
+
if (!apiKey)
|
|
296
328
|
return true;
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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,
|
|
374
|
+
sendJsonRpcError(res, 403, JSON_RPC_SERVER_ERROR, 'Forbidden: disallowed origin');
|
|
320
375
|
return;
|
|
321
376
|
}
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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,
|
|
395
|
+
sendJsonRpcError(res, 503, JSON_RPC_SERVER_ERROR, 'Too many sessions');
|
|
376
396
|
return;
|
|
377
397
|
}
|
|
378
|
-
const
|
|
379
|
-
await
|
|
398
|
+
const session = await createHttpSession(options, sessions);
|
|
399
|
+
await handleSessionTransportRequest(session, req, res, body);
|
|
380
400
|
return;
|
|
381
401
|
}
|
|
382
|
-
sendJsonRpcError(res, 400,
|
|
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,
|
|
407
|
+
sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: Missing session ID');
|
|
388
408
|
return;
|
|
389
409
|
}
|
|
390
|
-
|
|
391
|
-
|
|
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: {
|
|
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
|
|
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,
|
|
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>;
|