@j0hanz/filesystem-mcp 1.9.1 → 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 +10 -10
- 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 +2 -0
- package/dist/lib/file-operations/search.js +59 -27
- package/dist/lib/fs-helpers.d.ts +3 -1
- package/dist/lib/fs-helpers.js +63 -0
- package/dist/lib/paths.js +79 -53
- 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 +36 -1
- package/dist/schemas.js +73 -3
- package/dist/server/bootstrap.js +85 -65
- 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,6 +58,12 @@ const HeadLinesSchema = z
|
|
|
57
58
|
.max(100000, 'Max: 100,000')
|
|
58
59
|
.optional()
|
|
59
60
|
.describe('Read first N lines');
|
|
61
|
+
const TailLinesSchema = z
|
|
62
|
+
.int({ error: 'Must be integer' })
|
|
63
|
+
.min(1, 'Min: 1')
|
|
64
|
+
.max(100000, 'Max: 100,000')
|
|
65
|
+
.optional()
|
|
66
|
+
.describe('Read last N lines');
|
|
60
67
|
const LineNumberSchema = z.int({ error: 'Must be integer' }).min(1, 'Min: 1');
|
|
61
68
|
function addReadRangeIssue(ctx, path, message) {
|
|
62
69
|
ctx.addIssue({
|
|
@@ -67,11 +74,15 @@ function addReadRangeIssue(ctx, path, message) {
|
|
|
67
74
|
}
|
|
68
75
|
const validateReadRange = (value, ctx) => {
|
|
69
76
|
const hasHead = value.head !== undefined;
|
|
77
|
+
const hasTail = value.tail !== undefined;
|
|
70
78
|
const hasStart = value.startLine !== undefined;
|
|
71
79
|
const hasEnd = value.endLine !== undefined;
|
|
72
80
|
if (hasHead && (hasStart || hasEnd)) {
|
|
73
81
|
addReadRangeIssue(ctx, 'head', "Cannot use 'head' with 'startLine'/'endLine'");
|
|
74
82
|
}
|
|
83
|
+
if (hasTail && (hasHead || hasStart || hasEnd)) {
|
|
84
|
+
addReadRangeIssue(ctx, 'tail', "Cannot use 'tail' with 'head'/'startLine'/'endLine'");
|
|
85
|
+
}
|
|
75
86
|
if (hasEnd && !hasStart) {
|
|
76
87
|
addReadRangeIssue(ctx, 'endLine', "'endLine' requires 'startLine'");
|
|
77
88
|
}
|
|
@@ -84,6 +95,7 @@ const validateReadRange = (value, ctx) => {
|
|
|
84
95
|
function createReadRangeInputFields(descriptions) {
|
|
85
96
|
return {
|
|
86
97
|
head: HeadLinesSchema.describe(descriptions.head),
|
|
98
|
+
tail: TailLinesSchema.describe(descriptions.tail),
|
|
87
99
|
startLine: LineNumberSchema.optional().describe(descriptions.startLine),
|
|
88
100
|
endLine: LineNumberSchema.optional().describe(descriptions.endLine),
|
|
89
101
|
};
|
|
@@ -193,6 +205,7 @@ export const TreeInputSchema = z.strictObject({
|
|
|
193
205
|
.describe(`Max entries. Default: ${DEFAULT_TREE_ENTRIES}`),
|
|
194
206
|
includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
|
|
195
207
|
includeIgnored: defaultFalseBoolean('Include ignored items. Disables .gitignore.'),
|
|
208
|
+
includeSizes: defaultFalseBoolean('Include file sizes in tree entries'),
|
|
196
209
|
});
|
|
197
210
|
export const SearchContentInputSchema = z.strictObject({
|
|
198
211
|
path: OptionalPathSchema.describe(DESC_PATH_ROOT),
|
|
@@ -227,15 +240,18 @@ export const SearchContentInputSchema = z.strictObject({
|
|
|
227
240
|
.describe('Glob for candidate files (e.g. "**/*.ts")'),
|
|
228
241
|
includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
|
|
229
242
|
includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, etc).'),
|
|
243
|
+
multiline: defaultFalseBoolean('Multi-line mode. ^ and $ match line boundaries when isRegex=true.'),
|
|
230
244
|
});
|
|
231
245
|
export const ReadFileInputSchema = z
|
|
232
246
|
.strictObject({
|
|
233
247
|
path: RequiredPathSchema.describe(DESC_PATH_REQUIRED),
|
|
234
248
|
...createReadRangeInputFields({
|
|
235
249
|
head: 'Read first N lines (preview)',
|
|
250
|
+
tail: 'Read last N lines',
|
|
236
251
|
startLine: 'Start line (1-based, inclusive)',
|
|
237
252
|
endLine: 'End line (1-based, inclusive). Requires startLine.',
|
|
238
253
|
}),
|
|
254
|
+
includeHash: defaultFalseBoolean('Include SHA-256 hash of full file content'),
|
|
239
255
|
})
|
|
240
256
|
.superRefine(validateReadRange);
|
|
241
257
|
export const ReadMultipleFilesInputSchema = z
|
|
@@ -247,6 +263,7 @@ export const ReadMultipleFilesInputSchema = z
|
|
|
247
263
|
.describe('Files to read. e.g. ["src/index.ts"]'),
|
|
248
264
|
...createReadRangeInputFields({
|
|
249
265
|
head: 'Read first N lines of each file',
|
|
266
|
+
tail: 'Read last N lines of each file',
|
|
250
267
|
startLine: 'Start line (1-based, inclusive) per file',
|
|
251
268
|
endLine: 'End line (1-based, inclusive) per file. Requires startLine.',
|
|
252
269
|
}),
|
|
@@ -336,6 +353,10 @@ export const SearchContentOutputSchema = SearchSummarySchema.extend({
|
|
|
336
353
|
.array(z.strictObject({
|
|
337
354
|
file: z.string().describe('Relative path'),
|
|
338
355
|
line: z.number(),
|
|
356
|
+
column: z
|
|
357
|
+
.number()
|
|
358
|
+
.optional()
|
|
359
|
+
.describe('Column of first match (0-based)'),
|
|
339
360
|
content: z.string(),
|
|
340
361
|
matchCount: z.number(),
|
|
341
362
|
contextBefore: z.array(z.string()).optional(),
|
|
@@ -370,8 +391,12 @@ const ReadResultSchema = z.strictObject({
|
|
|
370
391
|
truncated: z.boolean().optional().describe('Truncated?'),
|
|
371
392
|
resourceUri: z.string().optional().describe('Full content URI'),
|
|
372
393
|
totalLines: z.number().optional().describe('Total lines'),
|
|
373
|
-
readMode: z
|
|
394
|
+
readMode: z
|
|
395
|
+
.enum(['full', 'head', 'tail', 'range'])
|
|
396
|
+
.optional()
|
|
397
|
+
.describe('Mode'),
|
|
374
398
|
head: z.number().optional().describe('Head lines'),
|
|
399
|
+
tail: z.number().optional().describe('Tail lines'),
|
|
375
400
|
startLine: z.number().optional().describe('Start line'),
|
|
376
401
|
endLine: z.number().optional().describe('End line'),
|
|
377
402
|
linesRead: z.number().optional().describe('Lines read'),
|
|
@@ -380,12 +405,13 @@ const ReadResultSchema = z.strictObject({
|
|
|
380
405
|
export const ReadFileOutputSchema = ReadResultSchema.extend({
|
|
381
406
|
ok: z.boolean(),
|
|
382
407
|
path: z.string().optional(),
|
|
408
|
+
contentHash: z.string().optional().describe('SHA-256 of full file content'),
|
|
383
409
|
error: ErrorSchema.optional(),
|
|
384
410
|
});
|
|
385
411
|
const ReadMultipleFileResultSchema = ReadResultSchema.extend({
|
|
386
412
|
path: z.string().describe('File path'),
|
|
387
413
|
truncationReason: z
|
|
388
|
-
.enum(['head', 'range', 'externalized'])
|
|
414
|
+
.enum(['head', 'tail', 'range', 'externalized'])
|
|
389
415
|
.optional()
|
|
390
416
|
.describe('Why content was truncated'),
|
|
391
417
|
maxTotalSize: z.number().optional().describe('Max total size budget'),
|
|
@@ -470,6 +496,7 @@ export const EditFileOutputSchema = z.strictObject({
|
|
|
470
496
|
.array(z.string())
|
|
471
497
|
.optional()
|
|
472
498
|
.describe('Edits that could not be applied'),
|
|
499
|
+
diff: z.string().optional().describe('Unified diff of changes (dryRun)'),
|
|
473
500
|
error: ErrorSchema.optional(),
|
|
474
501
|
});
|
|
475
502
|
export const MoveFileInputSchema = z
|
|
@@ -544,6 +571,9 @@ export const DiffFilesOutputSchema = z.strictObject({
|
|
|
544
571
|
ok: z.boolean(),
|
|
545
572
|
diff: z.string().optional().describe('Unified diff content'),
|
|
546
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'),
|
|
547
577
|
truncated: z.boolean().optional().describe('Diff content truncated?'),
|
|
548
578
|
resourceUri: z.string().optional().describe('Full diff content URI'),
|
|
549
579
|
error: ErrorSchema.optional(),
|
|
@@ -552,6 +582,10 @@ export const ApplyPatchInputSchema = z.strictObject({
|
|
|
552
582
|
path: RequiredPathSchema.describe('Path to file to patch'),
|
|
553
583
|
patch: z
|
|
554
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
|
+
})
|
|
555
589
|
.describe('Unified diff with @@ hunk headers. Generate with `diff_files`.'),
|
|
556
590
|
fuzzFactor: z
|
|
557
591
|
.int({ error: 'Must be integer' })
|
|
@@ -574,6 +608,20 @@ export const ApplyPatchOutputSchema = z.strictObject({
|
|
|
574
608
|
ok: z.boolean(),
|
|
575
609
|
path: z.string().optional(),
|
|
576
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'),
|
|
577
625
|
error: ErrorSchema.optional(),
|
|
578
626
|
});
|
|
579
627
|
export const SearchAndReplaceInputSchema = z.strictObject({
|
|
@@ -582,16 +630,24 @@ export const SearchAndReplaceInputSchema = z.strictObject({
|
|
|
582
630
|
.string()
|
|
583
631
|
.min(1, 'Pattern required')
|
|
584
632
|
.max(1000, 'Max 1000 chars')
|
|
633
|
+
.optional()
|
|
634
|
+
.default('**/*')
|
|
585
635
|
.refine((val) => isSafeGlobPattern(val), {
|
|
586
636
|
error: 'Invalid glob or unsafe path (absolute/.. forbidden)',
|
|
587
637
|
})
|
|
588
|
-
.describe('Glob
|
|
638
|
+
.describe('Glob to filter files. Default: **/*'),
|
|
589
639
|
searchPattern: z
|
|
590
640
|
.string()
|
|
591
641
|
.min(1, 'Search pattern required')
|
|
642
|
+
.max(1000, 'Max 1000 chars')
|
|
592
643
|
.describe('Text to search for. Literal by default; RE2 regex when `isRegex=true`.'),
|
|
593
644
|
replacement: z.string().describe('Replacement text'),
|
|
594
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.'),
|
|
595
651
|
dryRun: defaultFalseBoolean('Preview matches without writing. Check changedFiles and matches in the response before committing.'),
|
|
596
652
|
includeHidden: z
|
|
597
653
|
.boolean()
|
|
@@ -605,6 +661,12 @@ export const SearchAndReplaceInputSchema = z.strictObject({
|
|
|
605
661
|
.boolean()
|
|
606
662
|
.optional()
|
|
607
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'),
|
|
608
670
|
});
|
|
609
671
|
export const SearchAndReplaceOutputSchema = z.strictObject({
|
|
610
672
|
ok: z.boolean(),
|
|
@@ -631,6 +693,14 @@ export const SearchAndReplaceOutputSchema = z.strictObject({
|
|
|
631
693
|
.optional()
|
|
632
694
|
.describe('Changed file list truncated'),
|
|
633
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'),
|
|
634
704
|
dryRun: z.boolean().optional(),
|
|
635
705
|
error: ErrorSchema.optional(),
|
|
636
706
|
});
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -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 { registerGetHelpPrompt } from '../prompts.js';
|
|
16
|
+
import { registerAnalyzePathPrompt, registerCompareFilesPrompt, registerGetHelpPrompt, } 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';
|
|
@@ -52,9 +52,7 @@ export function buildServerCapabilities(options = {}) {
|
|
|
52
52
|
if (options.enableTaskToolRequests) {
|
|
53
53
|
// NOTE: enabling task tool requests requires the caller to configure
|
|
54
54
|
// an InMemoryTaskStore and InMemoryTaskMessageQueue on the McpServer.
|
|
55
|
-
// InMemoryTaskStore
|
|
56
|
-
// suitable for short-lived stdio sessions. Long-running HTTP servers should
|
|
57
|
-
// replace it with a TTL-evicting store to avoid unbounded memory growth.
|
|
55
|
+
// InMemoryTaskStore auto-evicts tasks after TTL via setTimeout.
|
|
58
56
|
capabilities.tasks = {
|
|
59
57
|
list: {},
|
|
60
58
|
cancel: {},
|
|
@@ -145,7 +143,7 @@ export async function createServer(options = {}) {
|
|
|
145
143
|
}),
|
|
146
144
|
};
|
|
147
145
|
if (taskToolSupport) {
|
|
148
|
-
// 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.
|
|
149
147
|
serverConfig.taskStore = new InMemoryTaskStore();
|
|
150
148
|
serverConfig.taskMessageQueue = new InMemoryTaskMessageQueue();
|
|
151
149
|
}
|
|
@@ -174,6 +172,8 @@ export async function createServer(options = {}) {
|
|
|
174
172
|
registerWorkflowGuideResource(server, localIcon);
|
|
175
173
|
registerToolInfoResource(server, localIcon);
|
|
176
174
|
registerGetHelpPrompt(server, serverInstructions, localIcon);
|
|
175
|
+
registerCompareFilesPrompt(server, localIcon);
|
|
176
|
+
registerAnalyzePathPrompt(server, localIcon);
|
|
177
177
|
registerResultResources(server, resourceStore, localIcon);
|
|
178
178
|
registerMetricsResource(server, localIcon);
|
|
179
179
|
registerCompletions(server, serverInstructions);
|
|
@@ -250,7 +250,11 @@ async function createHttpSession(options, sessions) {
|
|
|
250
250
|
const transport = new StreamableHTTPServerTransport({
|
|
251
251
|
sessionIdGenerator: () => randomUUID(),
|
|
252
252
|
onsessioninitialized: (sessionId) => {
|
|
253
|
-
sessions.set(sessionId, {
|
|
253
|
+
sessions.set(sessionId, {
|
|
254
|
+
server: mcpServer,
|
|
255
|
+
rootsManager,
|
|
256
|
+
transport,
|
|
257
|
+
});
|
|
254
258
|
rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
|
|
255
259
|
},
|
|
256
260
|
});
|
|
@@ -276,11 +280,57 @@ function sendJsonRpcError(res, status, code, message) {
|
|
|
276
280
|
}));
|
|
277
281
|
}
|
|
278
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;
|
|
279
289
|
function isAllowedOrigin(origin) {
|
|
280
290
|
if (origin === undefined)
|
|
281
291
|
return true; // Non-browser clients omit Origin.
|
|
282
292
|
return LOCALHOST_ORIGIN_RE.test(origin);
|
|
283
293
|
}
|
|
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;
|
|
305
|
+
}
|
|
306
|
+
const userKey = authHeader.slice(bearerPrefix.length);
|
|
307
|
+
if (userKey.length > MAX_BEARER_TOKEN_LENGTH) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
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
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
function ensureAuthorizedRequest(req, res) {
|
|
326
|
+
const apiKey = process.env['FILESYSTEM_MCP_API_KEY'];
|
|
327
|
+
if (!apiKey)
|
|
328
|
+
return true;
|
|
329
|
+
if (isAuthorizedBearer(apiKey, req.headers['authorization']))
|
|
330
|
+
return true;
|
|
331
|
+
writeUnauthorizedResponse(res);
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
284
334
|
function discardRequestBody(req) {
|
|
285
335
|
req.on('error', () => {
|
|
286
336
|
// Best effort drain to avoid corrupting keep-alive pipelines.
|
|
@@ -290,6 +340,14 @@ function discardRequestBody(req) {
|
|
|
290
340
|
async function handleSessionTransportRequest(session, req, res, body) {
|
|
291
341
|
await withAllowedDirectoriesState(session.rootsManager.getAllowedDirectoriesState(), () => session.transport.handleRequest(req, res, body));
|
|
292
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
|
+
}
|
|
293
351
|
function isLoopbackHttpHost(host) {
|
|
294
352
|
const normalized = host.trim().toLowerCase();
|
|
295
353
|
return (normalized === '127.0.0.1' ||
|
|
@@ -310,92 +368,49 @@ export async function startHttpServer(port, options) {
|
|
|
310
368
|
assertHttpBindingSecurity(httpHost);
|
|
311
369
|
async function handleMcpRequest(req, res) {
|
|
312
370
|
const { method } = req;
|
|
313
|
-
const
|
|
314
|
-
const rawSessionId = req.headers['mcp-session-id'];
|
|
315
|
-
const sessionId = typeof rawSessionId === 'string' &&
|
|
316
|
-
rawSessionId.length <= MAX_SESSION_ID_LENGTH
|
|
317
|
-
? rawSessionId
|
|
318
|
-
: undefined;
|
|
371
|
+
const sessionId = getSessionId(req);
|
|
319
372
|
const { origin } = req.headers;
|
|
320
373
|
if (!isAllowedOrigin(origin)) {
|
|
321
|
-
sendJsonRpcError(res, 403,
|
|
374
|
+
sendJsonRpcError(res, 403, JSON_RPC_SERVER_ERROR, 'Forbidden: disallowed origin');
|
|
322
375
|
return;
|
|
323
376
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const authHeader = req.headers['authorization'];
|
|
327
|
-
const bearerPrefix = 'Bearer ';
|
|
328
|
-
let authorized = false;
|
|
329
|
-
if (typeof authHeader === 'string' &&
|
|
330
|
-
authHeader.startsWith(bearerPrefix)) {
|
|
331
|
-
const userKey = authHeader.slice(bearerPrefix.length);
|
|
332
|
-
if (userKey.length <= 4096) {
|
|
333
|
-
const expectedHash = createHash('sha256').update(apiKey).digest();
|
|
334
|
-
const actualHash = createHash('sha256').update(userKey).digest();
|
|
335
|
-
authorized = timingSafeEqual(expectedHash, actualHash);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
if (!authorized) {
|
|
339
|
-
res.writeHead(401, {
|
|
340
|
-
'Content-Type': 'application/json',
|
|
341
|
-
'WWW-Authenticate': 'Bearer',
|
|
342
|
-
});
|
|
343
|
-
res.end(JSON.stringify({
|
|
344
|
-
jsonrpc: '2.0',
|
|
345
|
-
error: { code: -32000, message: 'Unauthorized' },
|
|
346
|
-
id: null,
|
|
347
|
-
}));
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
377
|
+
if (!ensureAuthorizedRequest(req, res))
|
|
378
|
+
return;
|
|
351
379
|
try {
|
|
352
380
|
if (method === 'POST') {
|
|
353
381
|
if (sessionId) {
|
|
354
|
-
|
|
355
|
-
|
|
382
|
+
const session = getSessionOrRespondNotFound(sessions, sessionId, res);
|
|
383
|
+
if (!session) {
|
|
356
384
|
discardRequestBody(req);
|
|
357
385
|
return;
|
|
358
386
|
}
|
|
359
387
|
const body = await readRequestBody(req);
|
|
360
|
-
|
|
361
|
-
if (session) {
|
|
362
|
-
await handleSessionTransportRequest(session, req, res, body);
|
|
363
|
-
}
|
|
364
|
-
else {
|
|
365
|
-
sendJsonRpcError(res, 404, -32000, 'Session not found');
|
|
366
|
-
}
|
|
388
|
+
await handleSessionTransportRequest(session, req, res, body);
|
|
367
389
|
return;
|
|
368
390
|
}
|
|
369
391
|
const body = await readRequestBody(req);
|
|
370
392
|
if (isInitializeRequest(body)) {
|
|
371
393
|
const maxSessions = parseEnvInt('FILESYSTEM_MCP_MAX_HTTP_SESSIONS', 100, 1, 10_000);
|
|
372
394
|
if (sessions.size >= maxSessions) {
|
|
373
|
-
sendJsonRpcError(res, 503,
|
|
395
|
+
sendJsonRpcError(res, 503, JSON_RPC_SERVER_ERROR, 'Too many sessions');
|
|
374
396
|
return;
|
|
375
397
|
}
|
|
376
398
|
const session = await createHttpSession(options, sessions);
|
|
377
399
|
await handleSessionTransportRequest(session, req, res, body);
|
|
378
400
|
return;
|
|
379
401
|
}
|
|
380
|
-
sendJsonRpcError(res, 400,
|
|
402
|
+
sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: No valid session ID provided');
|
|
381
403
|
discardRequestBody(req);
|
|
382
404
|
}
|
|
383
405
|
else if (method === 'GET' || method === 'DELETE') {
|
|
384
406
|
if (!sessionId) {
|
|
385
|
-
sendJsonRpcError(res, 400,
|
|
407
|
+
sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: Missing session ID');
|
|
386
408
|
return;
|
|
387
409
|
}
|
|
388
|
-
|
|
389
|
-
|
|
410
|
+
const session = getSessionOrRespondNotFound(sessions, sessionId, res);
|
|
411
|
+
if (!session)
|
|
390
412
|
return;
|
|
391
|
-
|
|
392
|
-
const session = sessions.get(sessionId);
|
|
393
|
-
if (session) {
|
|
394
|
-
await handleSessionTransportRequest(session, req, res);
|
|
395
|
-
}
|
|
396
|
-
else {
|
|
397
|
-
sendJsonRpcError(res, 404, -32000, 'Session not found');
|
|
398
|
-
}
|
|
413
|
+
await handleSessionTransportRequest(session, req, res);
|
|
399
414
|
}
|
|
400
415
|
else {
|
|
401
416
|
res.writeHead(405, {
|
|
@@ -404,21 +419,26 @@ export async function startHttpServer(port, options) {
|
|
|
404
419
|
});
|
|
405
420
|
res.end(JSON.stringify({
|
|
406
421
|
jsonrpc: '2.0',
|
|
407
|
-
error: {
|
|
422
|
+
error: {
|
|
423
|
+
code: JSON_RPC_SERVER_ERROR,
|
|
424
|
+
message: 'Method Not Allowed',
|
|
425
|
+
},
|
|
408
426
|
id: null,
|
|
409
427
|
}));
|
|
410
428
|
}
|
|
411
429
|
}
|
|
412
430
|
catch (error) {
|
|
413
431
|
if (error instanceof RequestBodyError && !res.headersSent) {
|
|
414
|
-
const rpcCode = error.statusCode === 413
|
|
432
|
+
const rpcCode = error.statusCode === 413
|
|
433
|
+
? JSON_RPC_INVALID_REQUEST
|
|
434
|
+
: JSON_RPC_PARSE_ERROR;
|
|
415
435
|
res.setHeader('Connection', 'close');
|
|
416
436
|
sendJsonRpcError(res, error.statusCode, rpcCode, error.message);
|
|
417
437
|
return;
|
|
418
438
|
}
|
|
419
439
|
console.error('[HTTP] Error handling request:', formatUnknownErrorMessage(error));
|
|
420
440
|
if (!res.headersSent) {
|
|
421
|
-
sendJsonRpcError(res, 500,
|
|
441
|
+
sendJsonRpcError(res, 500, JSON_RPC_INTERNAL_ERROR, 'Internal Server Error');
|
|
422
442
|
}
|
|
423
443
|
}
|
|
424
444
|
}
|