@j0hanz/filesystem-mcp 1.17.0 → 1.18.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/dist/completions.js +83 -16
- package/dist/lib/constants.d.ts +4 -2
- package/dist/lib/constants.js +6 -2
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.js +1 -1
- package/dist/lib/fs-helpers.d.ts +2 -1
- package/dist/lib/fs-helpers.js +39 -22
- package/dist/prompts.js +20 -0
- package/dist/server/bootstrap.js +24 -40
- package/dist/server/event-store.d.ts +18 -0
- package/dist/server/event-store.js +71 -0
- package/dist/server/roots-manager.js +5 -4
- package/dist/server/task-store.d.ts +1 -0
- package/dist/server/task-store.js +23 -5
- package/dist/tools/apply-patch.js +15 -8
- package/dist/tools/calculate-hash.js +5 -5
- package/dist/tools/create-directory.js +5 -6
- package/dist/tools/delete-file.js +3 -3
- package/dist/tools/diff-files.js +5 -5
- package/dist/tools/edit-file.js +13 -8
- package/dist/tools/list-directory.js +3 -3
- package/dist/tools/move-file.js +5 -6
- package/dist/tools/read-multiple.js +14 -7
- package/dist/tools/read.js +4 -7
- package/dist/tools/replace-in-files.js +5 -5
- package/dist/tools/roots.js +3 -3
- package/dist/tools/search-content.js +5 -4
- package/dist/tools/search-files.js +5 -5
- package/dist/tools/shared.d.ts +15 -18
- package/dist/tools/shared.js +5 -17
- package/dist/tools/stat-many.js +7 -6
- package/dist/tools/stat.js +4 -4
- package/dist/tools/task-support.d.ts +5 -0
- package/dist/tools/task-support.js +83 -113
- package/dist/tools/tree.js +5 -5
- package/dist/tools/write-file.js +3 -3
- package/package.json +1 -1
package/dist/completions.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { readdir, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import { basename, dirname, isAbsolute, join, parse, resolve, sep, } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
3
4
|
import { getAllowedDirectories, isPathWithinDirectories, normalizePath, toPosixPath, } from './lib/paths.js';
|
|
4
5
|
import { isRecord } from './lib/utils.js';
|
|
5
6
|
import { getSortedToolContracts } from './resources/tool-info.js';
|
|
@@ -46,9 +47,63 @@ const PATH_ARGUMENTS = new Set([
|
|
|
46
47
|
const DESTINATION_CONTEXT_KEYS = ['source', 'path', 'cwd', 'root'];
|
|
47
48
|
const PRIMARY_PATH_CONTEXT_KEYS = ['path', 'cwd', 'root'];
|
|
48
49
|
const DEFAULT_CONTEXT_KEYS = ['path', 'source', 'cwd', 'root'];
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
function extractEnumValuesFromSchema(argumentName, schema) {
|
|
51
|
+
const properties = getInputSchemaProperties(schema);
|
|
52
|
+
if (!properties || typeof properties !== 'object')
|
|
53
|
+
return undefined;
|
|
54
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
55
|
+
if (key.toLowerCase() !== argumentName)
|
|
56
|
+
continue;
|
|
57
|
+
if (value === null || typeof value !== 'object' || !('enum' in value)) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const enumValues = value.enum;
|
|
61
|
+
if (!Array.isArray(enumValues))
|
|
62
|
+
return undefined;
|
|
63
|
+
const stringValues = enumValues.filter((entry) => typeof entry === 'string');
|
|
64
|
+
return stringValues.length > 0 ? stringValues : undefined;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
function getInputSchemaProperties(schema) {
|
|
69
|
+
const { properties } = z.toJSONSchema(schema, { io: 'input' });
|
|
70
|
+
if (!properties || typeof properties !== 'object')
|
|
71
|
+
return undefined;
|
|
72
|
+
return properties;
|
|
73
|
+
}
|
|
74
|
+
function intersectEnumValueSets(valueSets) {
|
|
75
|
+
const [firstSet, ...restSets] = valueSets;
|
|
76
|
+
if (!firstSet)
|
|
77
|
+
return [];
|
|
78
|
+
return firstSet.filter((value) => restSets.every((candidateSet) => candidateSet.includes(value)));
|
|
79
|
+
}
|
|
80
|
+
function buildEnumArgumentValues() {
|
|
81
|
+
const valuesByArgument = new Map();
|
|
82
|
+
for (const contract of getSortedToolContracts()) {
|
|
83
|
+
const properties = getInputSchemaProperties(contract.inputSchema);
|
|
84
|
+
if (!properties || typeof properties !== 'object')
|
|
85
|
+
continue;
|
|
86
|
+
for (const key of Object.keys(properties)) {
|
|
87
|
+
const normalizedKey = key.toLowerCase();
|
|
88
|
+
const values = extractEnumValuesFromSchema(normalizedKey, contract.inputSchema);
|
|
89
|
+
if (!values)
|
|
90
|
+
continue;
|
|
91
|
+
const existing = valuesByArgument.get(normalizedKey) ?? [];
|
|
92
|
+
existing.push(values);
|
|
93
|
+
valuesByArgument.set(normalizedKey, existing);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const result = new Map();
|
|
97
|
+
for (const [argumentName, valueSets] of valuesByArgument) {
|
|
98
|
+
const values = valueSets.length === 1
|
|
99
|
+
? (valueSets[0] ?? [])
|
|
100
|
+
: intersectEnumValueSets(valueSets);
|
|
101
|
+
if (values.length > 0) {
|
|
102
|
+
result.set(argumentName, values);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
52
107
|
function isPathLikeArgumentName(argName) {
|
|
53
108
|
return (PATH_ARGUMENTS.has(argName) ||
|
|
54
109
|
argName.endsWith('paths') ||
|
|
@@ -58,8 +113,8 @@ function isPathLikeArgumentName(argName) {
|
|
|
58
113
|
argName.endsWith('dirs') ||
|
|
59
114
|
argName.endsWith('dir'));
|
|
60
115
|
}
|
|
61
|
-
function getEnumCompletions(argName, currentValue) {
|
|
62
|
-
const values =
|
|
116
|
+
function getEnumCompletions(argName, currentValue, enumArgumentValues) {
|
|
117
|
+
const values = enumArgumentValues.get(argName);
|
|
63
118
|
if (!values)
|
|
64
119
|
return undefined;
|
|
65
120
|
const prefix = currentValue.toLowerCase();
|
|
@@ -267,18 +322,29 @@ function resolveContextCandidatePath(candidate, allowed) {
|
|
|
267
322
|
return resolveNamedRootPath(candidate, allowed);
|
|
268
323
|
}
|
|
269
324
|
async function toAllowedContextDirectory(resolved, allowed) {
|
|
270
|
-
|
|
271
|
-
|
|
325
|
+
const parent = dirname(resolved);
|
|
326
|
+
if (await isAllowedCompletionDirectory(resolved, allowed)) {
|
|
327
|
+
return resolved;
|
|
328
|
+
}
|
|
329
|
+
return (await isAllowedCompletionDirectory(parent, allowed))
|
|
330
|
+
? parent
|
|
331
|
+
: undefined;
|
|
332
|
+
}
|
|
333
|
+
async function isAllowedCompletionDirectory(path, allowed) {
|
|
334
|
+
if (!isPathWithinDirectories(path, allowed))
|
|
335
|
+
return false;
|
|
272
336
|
try {
|
|
273
|
-
const stats = await
|
|
274
|
-
|
|
275
|
-
|
|
337
|
+
const [stats, resolvedRealPath] = await Promise.all([
|
|
338
|
+
stat(path),
|
|
339
|
+
realpath(path),
|
|
340
|
+
]);
|
|
341
|
+
if (!stats.isDirectory())
|
|
342
|
+
return false;
|
|
343
|
+
return isPathWithinDirectories(normalizePath(resolvedRealPath), allowed);
|
|
276
344
|
}
|
|
277
345
|
catch {
|
|
278
|
-
|
|
346
|
+
return false;
|
|
279
347
|
}
|
|
280
|
-
const parent = dirname(resolved);
|
|
281
|
-
return isPathWithinDirectories(parent, allowed) ? parent : undefined;
|
|
282
348
|
}
|
|
283
349
|
async function resolveContextBaseDirectory(argumentName, contextArguments, allowed) {
|
|
284
350
|
if (!hasContextArguments(contextArguments)) {
|
|
@@ -371,7 +437,7 @@ function getSearchContext(currentValue, allowed, contextBase) {
|
|
|
371
437
|
}
|
|
372
438
|
async function findMatchesInDirectory(searchDir, prefix, allowed) {
|
|
373
439
|
const matches = [];
|
|
374
|
-
if (!
|
|
440
|
+
if (!(await isAllowedCompletionDirectory(searchDir, allowed))) {
|
|
375
441
|
return matches;
|
|
376
442
|
}
|
|
377
443
|
try {
|
|
@@ -460,6 +526,7 @@ function handleTopicAndToolCompletions(ref, argName, argumentValue, topicValues,
|
|
|
460
526
|
export function registerCompletions(server, instructions = '') {
|
|
461
527
|
const topicValues = extractTopicCompletions(instructions);
|
|
462
528
|
const toolNameValues = extractToolNameCompletions();
|
|
529
|
+
const enumArgumentValues = buildEnumArgumentValues();
|
|
463
530
|
server.server.setRequestHandler('completion/complete', async (request) => {
|
|
464
531
|
const { params } = request;
|
|
465
532
|
const { argument, ref } = params;
|
|
@@ -467,7 +534,7 @@ export function registerCompletions(server, instructions = '') {
|
|
|
467
534
|
const predef = handleTopicAndToolCompletions(ref, argName, argument.value, topicValues, toolNameValues);
|
|
468
535
|
if (predef)
|
|
469
536
|
return predef;
|
|
470
|
-
const enumResult = getEnumCompletions(argName, argument.value);
|
|
537
|
+
const enumResult = getEnumCompletions(argName, argument.value, enumArgumentValues);
|
|
471
538
|
if (enumResult) {
|
|
472
539
|
return buildCompletionResponse(enumResult);
|
|
473
540
|
}
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -5,9 +5,11 @@ export declare const DEFAULT_TASK_TTL_MS: number;
|
|
|
5
5
|
export declare const MAX_TASK_TTL_MS: number;
|
|
6
6
|
export declare const MAX_CONCURRENT_TASKS: number;
|
|
7
7
|
export declare const TASK_CANCEL_POLL_MS = 2000;
|
|
8
|
-
export declare
|
|
8
|
+
export declare function getInitHandshakeTimeoutMs(): number;
|
|
9
9
|
export declare const INIT_TIMEOUT_CLOSE: boolean;
|
|
10
|
-
export declare const TASK_POLL_INTERVAL_MS =
|
|
10
|
+
export declare const TASK_POLL_INTERVAL_MS = 500;
|
|
11
|
+
/** How long cancelled-task results are retained before lazy eviction. */
|
|
12
|
+
export declare const CANCELLED_RESULT_TTL_MS: number;
|
|
11
13
|
export declare const PARALLEL_CONCURRENCY: number;
|
|
12
14
|
export declare const MAX_SEARCHABLE_FILE_SIZE: number;
|
|
13
15
|
export declare const MAX_TEXT_FILE_SIZE: number;
|
package/dist/lib/constants.js
CHANGED
|
@@ -76,9 +76,13 @@ export const DEFAULT_TASK_TTL_MS = 5 * 60 * 1000;
|
|
|
76
76
|
export const MAX_TASK_TTL_MS = parseEnvInt('FILESYSTEM_MCP_MAX_TASK_TTL_MS', 60 * 60 * 1000, 1_000, 24 * 60 * 60 * 1000);
|
|
77
77
|
export const MAX_CONCURRENT_TASKS = parseEnvInt('FILESYSTEM_MCP_MAX_CONCURRENT_TASKS', 100, 1, 10_000);
|
|
78
78
|
export const TASK_CANCEL_POLL_MS = 2_000;
|
|
79
|
-
export
|
|
79
|
+
export function getInitHandshakeTimeoutMs() {
|
|
80
|
+
return parseEnvInt('FS_INIT_HANDSHAKE_TIMEOUT_MS', 30_000, 1_000, 300_000);
|
|
81
|
+
}
|
|
80
82
|
export const INIT_TIMEOUT_CLOSE = parseTrueEnvFlag(process.env['FS_INIT_TIMEOUT_CLOSE']);
|
|
81
|
-
export const TASK_POLL_INTERVAL_MS =
|
|
83
|
+
export const TASK_POLL_INTERVAL_MS = 500;
|
|
84
|
+
/** How long cancelled-task results are retained before lazy eviction. */
|
|
85
|
+
export const CANCELLED_RESULT_TTL_MS = 2 * 60 * 1_000; // 2 minutes
|
|
82
86
|
// Auto-tuned parallelism based on CPU cores (no env override)
|
|
83
87
|
const BYTES_PER_PARALLEL_TASK = 64 * MIB;
|
|
84
88
|
const BYTES_PER_SEARCH_WORKER = 128 * MIB;
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare class McpError extends Error {
|
|
|
22
22
|
static accessDenied(message: string, path?: string, details?: Record<string, unknown>, cause?: unknown): McpError;
|
|
23
23
|
static timeout(message: string, path?: string, details?: Record<string, unknown>, cause?: unknown): McpError;
|
|
24
24
|
}
|
|
25
|
+
export declare function classifyError(error: unknown): ErrorCode;
|
|
25
26
|
export declare function createDetailedError(error: unknown, path?: string, additionalDetails?: Record<string, unknown>): DetailedError;
|
|
26
27
|
export declare function formatDetailedError(error: DetailedError): string;
|
|
27
28
|
export declare function getSuggestion(code: ErrorCode): string | undefined;
|
package/dist/lib/errors.js
CHANGED
|
@@ -236,7 +236,7 @@ function classifyMessageError(error) {
|
|
|
236
236
|
}
|
|
237
237
|
return undefined;
|
|
238
238
|
}
|
|
239
|
-
function classifyError(error) {
|
|
239
|
+
export function classifyError(error) {
|
|
240
240
|
let timeoutCode;
|
|
241
241
|
let fallbackCode;
|
|
242
242
|
const terminalCode = walkErrorChain(error, (candidate) => {
|
package/dist/lib/fs-helpers.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Stats } from 'node:fs';
|
|
2
2
|
import { type FileHandle } from 'node:fs/promises';
|
|
3
3
|
import type { FileType } from '../config.js';
|
|
4
4
|
interface ParallelResult<R> {
|
|
@@ -12,6 +12,7 @@ export declare function processInParallel<T, R>(items: T[], processor: (item: T)
|
|
|
12
12
|
export declare function getFileType(stats: Stats): FileType;
|
|
13
13
|
export declare function isHidden(name: string): boolean;
|
|
14
14
|
export declare function isProbablyBinary(filePath: string, existingHandle?: FileHandle, signal?: AbortSignal): Promise<boolean>;
|
|
15
|
+
export declare function calculateFileContentHash(filePath: string, signal?: AbortSignal): Promise<string>;
|
|
15
16
|
type ReadMode = 'head' | 'full' | 'range' | 'tail';
|
|
16
17
|
interface ReadFileOptions {
|
|
17
18
|
encoding?: BufferEncoding;
|
package/dist/lib/fs-helpers.js
CHANGED
|
@@ -51,9 +51,11 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
51
51
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
52
|
});
|
|
53
53
|
import { isUtf8 } from 'node:buffer';
|
|
54
|
-
import { randomUUID } from 'node:crypto';
|
|
54
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
55
|
+
import { createReadStream } from 'node:fs';
|
|
55
56
|
import { open, rename, stat, unlink, writeFile, } from 'node:fs/promises';
|
|
56
57
|
import { extname } from 'node:path';
|
|
58
|
+
import { pipeline } from 'node:stream/promises';
|
|
57
59
|
import { assertNotAborted, withAbort } from './abort.js';
|
|
58
60
|
import { BINARY_CHECK_BUFFER_SIZE, KNOWN_BINARY_EXTENSIONS, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from './constants.js';
|
|
59
61
|
import { ErrorCode, McpError, normalizeUnknownError } from './errors.js';
|
|
@@ -192,6 +194,11 @@ function isBinarySlice(slice) {
|
|
|
192
194
|
return true;
|
|
193
195
|
return !isUtf8(slice);
|
|
194
196
|
}
|
|
197
|
+
export async function calculateFileContentHash(filePath, signal) {
|
|
198
|
+
const hasher = createHash('sha256');
|
|
199
|
+
await pipeline(createReadStream(filePath, { signal, highWaterMark: STREAM_CHUNK_SIZE }), hasher, { signal });
|
|
200
|
+
return hasher.digest('hex');
|
|
201
|
+
}
|
|
195
202
|
function validateReadOptions(options) {
|
|
196
203
|
const hasHead = options.head !== undefined;
|
|
197
204
|
const hasTail = options.tail !== undefined;
|
|
@@ -298,24 +305,6 @@ async function readFileBufferWithLimit(handle, maxSize, requestedPath, signal) {
|
|
|
298
305
|
}
|
|
299
306
|
return Buffer.concat(chunks, totalSize);
|
|
300
307
|
}
|
|
301
|
-
async function headFile(handle, numLines, encoding = 'utf-8', maxBytesRead, signal) {
|
|
302
|
-
assertNotAborted(signal);
|
|
303
|
-
const lines = [];
|
|
304
|
-
let estimatedBytes = 0;
|
|
305
|
-
const hasMaxBytes = maxBytesRead !== undefined;
|
|
306
|
-
const newlineBytes = Buffer.byteLength('\n', encoding);
|
|
307
|
-
for await (const line of handle.readLines({ encoding, signal })) {
|
|
308
|
-
lines.push(line);
|
|
309
|
-
if (lines.length >= numLines)
|
|
310
|
-
break;
|
|
311
|
-
if (!hasMaxBytes)
|
|
312
|
-
continue;
|
|
313
|
-
estimatedBytes += Buffer.byteLength(line, encoding) + newlineBytes;
|
|
314
|
-
if (estimatedBytes >= maxBytesRead)
|
|
315
|
-
break;
|
|
316
|
-
}
|
|
317
|
-
return lines.join('\n');
|
|
318
|
-
}
|
|
319
308
|
function countLines(content) {
|
|
320
309
|
if (content.length === 0)
|
|
321
310
|
return 0;
|
|
@@ -327,9 +316,37 @@ function countLines(content) {
|
|
|
327
316
|
return count;
|
|
328
317
|
}
|
|
329
318
|
async function readHeadContent(handle, head, options) {
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
319
|
+
assertNotAborted(options.signal);
|
|
320
|
+
const lines = [];
|
|
321
|
+
let estimatedBytes = 0;
|
|
322
|
+
const newlineBytes = Buffer.byteLength('\n', options.encoding);
|
|
323
|
+
const iterator = handle
|
|
324
|
+
.readLines({ encoding: options.encoding, signal: options.signal })[Symbol.asyncIterator]();
|
|
325
|
+
let hasMoreLines = false;
|
|
326
|
+
let next = await iterator.next();
|
|
327
|
+
try {
|
|
328
|
+
while (!next.done) {
|
|
329
|
+
const line = next.value;
|
|
330
|
+
lines.push(line);
|
|
331
|
+
estimatedBytes +=
|
|
332
|
+
Buffer.byteLength(line, options.encoding) + newlineBytes;
|
|
333
|
+
if (estimatedBytes >= options.maxSize) {
|
|
334
|
+
hasMoreLines = true;
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
if (lines.length === head) {
|
|
338
|
+
const peek = await iterator.next();
|
|
339
|
+
hasMoreLines = !peek.done;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
next = await iterator.next();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await iterator.return?.();
|
|
347
|
+
}
|
|
348
|
+
const content = lines.join('\n');
|
|
349
|
+
const linesRead = lines.length;
|
|
333
350
|
return {
|
|
334
351
|
content,
|
|
335
352
|
truncated: hasMoreLines,
|
package/dist/prompts.js
CHANGED
|
@@ -57,6 +57,10 @@ export function registerGetHelpPrompt(server, instructions, iconInfo) {
|
|
|
57
57
|
content: {
|
|
58
58
|
type: 'text',
|
|
59
59
|
text,
|
|
60
|
+
annotations: {
|
|
61
|
+
audience: ['assistant'],
|
|
62
|
+
priority: 1,
|
|
63
|
+
},
|
|
60
64
|
},
|
|
61
65
|
},
|
|
62
66
|
],
|
|
@@ -81,6 +85,10 @@ export function registerCompareFilesPrompt(server, iconInfo) {
|
|
|
81
85
|
content: {
|
|
82
86
|
type: 'text',
|
|
83
87
|
text: `Compare files and explain differences.\n\n1. Call \`diff_files\` with:\n - original: ${original}\n - modified: ${modified}\n2. Summarize: additions, deletions, and semantic changes.\n3. Flag any potential issues (conflicts, regressions, breaking changes).`,
|
|
88
|
+
annotations: {
|
|
89
|
+
audience: ['assistant'],
|
|
90
|
+
priority: 1,
|
|
91
|
+
},
|
|
84
92
|
},
|
|
85
93
|
},
|
|
86
94
|
],
|
|
@@ -103,6 +111,10 @@ export function registerAnalyzePathPrompt(server, iconInfo) {
|
|
|
103
111
|
content: {
|
|
104
112
|
type: 'text',
|
|
105
113
|
text: `Analyze the path: ${targetPath}\n\n1. Call \`stat\` to determine if it is a file or directory.\n2. If file: call \`read\` with \`includeHash: true\` and summarize contents.\n3. If directory: call \`tree\` (maxDepth: 3) and \`ls\` to summarize structure.\n4. Report: type, size, permissions, key observations.`,
|
|
114
|
+
annotations: {
|
|
115
|
+
audience: ['assistant'],
|
|
116
|
+
priority: 1,
|
|
117
|
+
},
|
|
106
118
|
},
|
|
107
119
|
},
|
|
108
120
|
],
|
|
@@ -138,6 +150,10 @@ export function registerGetToolHelpPrompt(server, iconInfo) {
|
|
|
138
150
|
type: 'text',
|
|
139
151
|
text: `Use the embedded contract for \`${toolName}\` as the authoritative reference. ` +
|
|
140
152
|
'Summarize when to use it, its key constraints, and the safest next action.',
|
|
153
|
+
annotations: {
|
|
154
|
+
audience: ['assistant'],
|
|
155
|
+
priority: 1,
|
|
156
|
+
},
|
|
141
157
|
},
|
|
142
158
|
},
|
|
143
159
|
{
|
|
@@ -149,6 +165,10 @@ export function registerGetToolHelpPrompt(server, iconInfo) {
|
|
|
149
165
|
mimeType: 'text/markdown',
|
|
150
166
|
text: toolInfo,
|
|
151
167
|
},
|
|
168
|
+
annotations: {
|
|
169
|
+
audience: ['assistant'],
|
|
170
|
+
priority: 1,
|
|
171
|
+
},
|
|
152
172
|
},
|
|
153
173
|
},
|
|
154
174
|
],
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
|
2
|
-
import { InMemoryTaskMessageQueue, isInitializeRequest,
|
|
2
|
+
import { InMemoryTaskMessageQueue, isInitializeRequest, localhostAllowedHostnames, McpServer, StdioServerTransport, validateHostHeader, } from '@modelcontextprotocol/server';
|
|
3
3
|
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { channel } from 'node:diagnostics_channel';
|
|
5
5
|
import { readFile } from 'node:fs/promises';
|
|
6
6
|
import { createServer as createHttpServer, } from 'node:http';
|
|
7
|
-
import {
|
|
7
|
+
import { inspect } from 'node:util';
|
|
8
|
+
import { DEFAULT_LOG_LEVEL, getInitHandshakeTimeoutMs, INIT_TIMEOUT_CLOSE, parseEnvInt, } from '../lib/constants.js';
|
|
8
9
|
import { formatUnknownErrorMessage } from '../lib/errors.js';
|
|
9
10
|
import { createLoggingState, Logger, logToMcp, SessionContext, } from '../lib/logger.js';
|
|
10
11
|
import { withAllowedDirectoriesState } from '../lib/paths.js';
|
|
@@ -16,6 +17,7 @@ import { registerInstructionResource, registerMetricsResource, registerResultRes
|
|
|
16
17
|
import { buildServerInstructions } from '../resources/generated-instructions.js';
|
|
17
18
|
import { registerAllTools } from '../tools.js';
|
|
18
19
|
import { withDefaultIcons } from '../tools/shared.js';
|
|
20
|
+
import { InMemoryEventStore } from './event-store.js';
|
|
19
21
|
import { RootsManager } from './roots-manager.js';
|
|
20
22
|
import { createTaskStore } from './task-store.js';
|
|
21
23
|
function buildServerCapabilities(options = {}) {
|
|
@@ -44,9 +46,17 @@ const activeServers = new Map();
|
|
|
44
46
|
// For stdio (single session without a specific ID)
|
|
45
47
|
let stdioServer;
|
|
46
48
|
function stringifyData(data) {
|
|
47
|
-
if (
|
|
49
|
+
if (data === undefined)
|
|
48
50
|
return '';
|
|
49
|
-
|
|
51
|
+
if (typeof data === 'string')
|
|
52
|
+
return ` ${data}`;
|
|
53
|
+
if (data === null ||
|
|
54
|
+
typeof data === 'number' ||
|
|
55
|
+
typeof data === 'boolean' ||
|
|
56
|
+
typeof data === 'bigint') {
|
|
57
|
+
return ` ${String(data)}`;
|
|
58
|
+
}
|
|
59
|
+
return ` ${inspect(data, { depth: 4, colors: false, compact: 3 })}`;
|
|
50
60
|
}
|
|
51
61
|
channel('filesystem-mcp:log').subscribe((message) => {
|
|
52
62
|
const event = message;
|
|
@@ -212,7 +222,7 @@ async function readRequestBody(req) {
|
|
|
212
222
|
req.on('error', reject);
|
|
213
223
|
});
|
|
214
224
|
}
|
|
215
|
-
async function createHttpSession(options, sessions,
|
|
225
|
+
async function createHttpSession(options, sessions, eventStore) {
|
|
216
226
|
const mcpServer = await createServer(options);
|
|
217
227
|
const rootsManager = getRootsManager(mcpServer);
|
|
218
228
|
rootsManager.registerHandlers(mcpServer);
|
|
@@ -226,6 +236,7 @@ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
|
|
|
226
236
|
if (sessionId) {
|
|
227
237
|
sessions.delete(sessionId);
|
|
228
238
|
activeServers.delete(sessionId);
|
|
239
|
+
eventStore.delete(sessionId);
|
|
229
240
|
}
|
|
230
241
|
rootsManager.destroy();
|
|
231
242
|
};
|
|
@@ -235,12 +246,12 @@ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
|
|
|
235
246
|
};
|
|
236
247
|
const transport = new NodeStreamableHTTPServerTransport({
|
|
237
248
|
sessionIdGenerator: () => randomUUID(),
|
|
249
|
+
eventStore,
|
|
238
250
|
onsessioninitialized: (sessionId) => {
|
|
239
251
|
sessions.set(sessionId, {
|
|
240
252
|
server: mcpServer,
|
|
241
253
|
rootsManager,
|
|
242
254
|
transport,
|
|
243
|
-
negotiatedProtocolVersion,
|
|
244
255
|
createdAt: Date.now(),
|
|
245
256
|
cleanup,
|
|
246
257
|
close,
|
|
@@ -258,7 +269,6 @@ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
|
|
|
258
269
|
server: mcpServer,
|
|
259
270
|
rootsManager,
|
|
260
271
|
transport,
|
|
261
|
-
negotiatedProtocolVersion,
|
|
262
272
|
createdAt: Date.now(),
|
|
263
273
|
cleanup,
|
|
264
274
|
close,
|
|
@@ -309,29 +319,6 @@ function getSessionId(req) {
|
|
|
309
319
|
? rawSessionId
|
|
310
320
|
: undefined;
|
|
311
321
|
}
|
|
312
|
-
function getProtocolVersionHeader(req) {
|
|
313
|
-
const rawProtocolVersion = req.headers['mcp-protocol-version'];
|
|
314
|
-
return typeof rawProtocolVersion === 'string'
|
|
315
|
-
? rawProtocolVersion
|
|
316
|
-
: undefined;
|
|
317
|
-
}
|
|
318
|
-
function resolveNegotiatedProtocolVersion(requestedVersion) {
|
|
319
|
-
return SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
|
320
|
-
? requestedVersion
|
|
321
|
-
: LATEST_PROTOCOL_VERSION;
|
|
322
|
-
}
|
|
323
|
-
function ensureSessionProtocolVersion(req, res, session) {
|
|
324
|
-
const protocolVersion = getProtocolVersionHeader(req);
|
|
325
|
-
if (!protocolVersion) {
|
|
326
|
-
sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, 'Bad Request: Missing MCP-Protocol-Version header');
|
|
327
|
-
return false;
|
|
328
|
-
}
|
|
329
|
-
if (protocolVersion !== session.negotiatedProtocolVersion) {
|
|
330
|
-
sendJsonRpcError(res, 400, JSON_RPC_SERVER_ERROR, `Bad Request: MCP-Protocol-Version must match negotiated version ${session.negotiatedProtocolVersion}`);
|
|
331
|
-
return false;
|
|
332
|
-
}
|
|
333
|
-
return true;
|
|
334
|
-
}
|
|
335
322
|
function isAuthorizedBearer(apiKey, authHeader) {
|
|
336
323
|
const bearerPrefix = 'Bearer ';
|
|
337
324
|
if (typeof authHeader !== 'string' || !authHeader.startsWith(bearerPrefix)) {
|
|
@@ -451,6 +438,7 @@ function handleHttpRequestError(error, res) {
|
|
|
451
438
|
}
|
|
452
439
|
export async function startHttpServer(port, options) {
|
|
453
440
|
const sessions = new Map();
|
|
441
|
+
const eventStore = new InMemoryEventStore();
|
|
454
442
|
const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
|
|
455
443
|
assertHttpBindingSecurity(httpHost);
|
|
456
444
|
let closingSessions;
|
|
@@ -460,6 +448,7 @@ export async function startHttpServer(port, options) {
|
|
|
460
448
|
closingSessions = (async () => {
|
|
461
449
|
const activeSessions = [...sessions.values()];
|
|
462
450
|
sessions.clear();
|
|
451
|
+
eventStore.clear();
|
|
463
452
|
await Promise.allSettled(activeSessions.map((session) => session.close()));
|
|
464
453
|
})();
|
|
465
454
|
await closingSessions;
|
|
@@ -471,10 +460,6 @@ export async function startHttpServer(port, options) {
|
|
|
471
460
|
discardRequestBody(req);
|
|
472
461
|
return;
|
|
473
462
|
}
|
|
474
|
-
if (!ensureSessionProtocolVersion(req, res, session)) {
|
|
475
|
-
discardRequestBody(req);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
463
|
const body = await readRequestBody(req);
|
|
479
464
|
await handleSessionTransportRequest(session, req, res, body);
|
|
480
465
|
return;
|
|
@@ -486,7 +471,7 @@ export async function startHttpServer(port, options) {
|
|
|
486
471
|
sendJsonRpcError(res, 503, JSON_RPC_SERVER_ERROR, 'Too many sessions');
|
|
487
472
|
return;
|
|
488
473
|
}
|
|
489
|
-
const session = await createHttpSession(options, sessions,
|
|
474
|
+
const session = await createHttpSession(options, sessions, eventStore);
|
|
490
475
|
await handleSessionTransportRequest(session, req, res, body);
|
|
491
476
|
return;
|
|
492
477
|
}
|
|
@@ -501,8 +486,6 @@ export async function startHttpServer(port, options) {
|
|
|
501
486
|
const session = getSessionOrRespondNotFound(sessions, sessionId, res);
|
|
502
487
|
if (!session)
|
|
503
488
|
return;
|
|
504
|
-
if (!ensureSessionProtocolVersion(req, res, session))
|
|
505
|
-
return;
|
|
506
489
|
await handleSessionTransportRequest(session, req, res);
|
|
507
490
|
}
|
|
508
491
|
async function dispatchMcpMethod(method, req, res, sessionId) {
|
|
@@ -533,14 +516,15 @@ export async function startHttpServer(port, options) {
|
|
|
533
516
|
handleHttpRequestError(error, res);
|
|
534
517
|
}
|
|
535
518
|
}
|
|
536
|
-
const
|
|
519
|
+
const initHandshakeTimeoutMs = getInitHandshakeTimeoutMs();
|
|
520
|
+
const SWEEP_INTERVAL_MS = initHandshakeTimeoutMs * 2;
|
|
537
521
|
const sweepTimer = setInterval(() => {
|
|
538
522
|
const now = Date.now();
|
|
539
523
|
for (const [sessionId, session] of sessions) {
|
|
540
524
|
if (!session.rootsManager.isInitialized() &&
|
|
541
|
-
now - session.createdAt >
|
|
525
|
+
now - session.createdAt > initHandshakeTimeoutMs) {
|
|
542
526
|
Logger.warn(`[HTTP] Evicting stale session ${sessionId}: client never sent notifications/initialized`);
|
|
543
|
-
session.
|
|
527
|
+
session.close().catch((err) => {
|
|
544
528
|
Logger.error(`[HTTP] Error closing stale session ${sessionId}:`, formatUnknownErrorMessage(err));
|
|
545
529
|
});
|
|
546
530
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { JSONRPCMessage } from '@modelcontextprotocol/server';
|
|
2
|
+
export declare class InMemoryEventStore {
|
|
3
|
+
private streams;
|
|
4
|
+
private eventIdToStreamId;
|
|
5
|
+
storeEvent(streamId: string, message: JSONRPCMessage): Promise<string>;
|
|
6
|
+
getStreamIdForEventId(eventId: string): Promise<string | undefined>;
|
|
7
|
+
replayEventsAfter(lastEventId: string, callbacks: {
|
|
8
|
+
send: (eventId: string, message: JSONRPCMessage) => Promise<void>;
|
|
9
|
+
}): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Cleans up all events for a given streamId.
|
|
12
|
+
*/
|
|
13
|
+
delete(streamId: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* Cleans up all streams.
|
|
16
|
+
*/
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
const MAX_EVENTS_PER_STREAM = 1000;
|
|
3
|
+
export class InMemoryEventStore {
|
|
4
|
+
// Map of streamId -> StoredEvent[]
|
|
5
|
+
streams = new Map();
|
|
6
|
+
// Map of eventId -> streamId for fast lookup
|
|
7
|
+
eventIdToStreamId = new Map();
|
|
8
|
+
storeEvent(streamId, message) {
|
|
9
|
+
const eventId = randomUUID();
|
|
10
|
+
let stream = this.streams.get(streamId);
|
|
11
|
+
if (!stream) {
|
|
12
|
+
stream = [];
|
|
13
|
+
this.streams.set(streamId, stream);
|
|
14
|
+
}
|
|
15
|
+
// Add new event
|
|
16
|
+
stream.push({ id: eventId, message });
|
|
17
|
+
this.eventIdToStreamId.set(eventId, streamId);
|
|
18
|
+
// Enforce limits
|
|
19
|
+
if (stream.length > MAX_EVENTS_PER_STREAM) {
|
|
20
|
+
const removed = stream.shift();
|
|
21
|
+
if (removed) {
|
|
22
|
+
this.eventIdToStreamId.delete(removed.id);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return Promise.resolve(eventId);
|
|
26
|
+
}
|
|
27
|
+
getStreamIdForEventId(eventId) {
|
|
28
|
+
return Promise.resolve(this.eventIdToStreamId.get(eventId));
|
|
29
|
+
}
|
|
30
|
+
async replayEventsAfter(lastEventId, callbacks) {
|
|
31
|
+
const streamId = this.eventIdToStreamId.get(lastEventId);
|
|
32
|
+
if (!streamId) {
|
|
33
|
+
throw new Error(`Event ID ${lastEventId} not found or expired`);
|
|
34
|
+
}
|
|
35
|
+
const stream = this.streams.get(streamId);
|
|
36
|
+
if (!stream) {
|
|
37
|
+
throw new Error(`Stream ${streamId} not found`);
|
|
38
|
+
}
|
|
39
|
+
const eventIndex = stream.findIndex((e) => e.id === lastEventId);
|
|
40
|
+
if (eventIndex === -1) {
|
|
41
|
+
throw new Error(`Event ID ${lastEventId} not found in stream ${streamId}`);
|
|
42
|
+
}
|
|
43
|
+
// Replay all events after the found index
|
|
44
|
+
for (let i = eventIndex + 1; i < stream.length; i++) {
|
|
45
|
+
const event = stream[i];
|
|
46
|
+
if (event) {
|
|
47
|
+
await callbacks.send(event.id, event.message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return streamId;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Cleans up all events for a given streamId.
|
|
54
|
+
*/
|
|
55
|
+
delete(streamId) {
|
|
56
|
+
const stream = this.streams.get(streamId);
|
|
57
|
+
if (stream) {
|
|
58
|
+
for (const event of stream) {
|
|
59
|
+
this.eventIdToStreamId.delete(event.id);
|
|
60
|
+
}
|
|
61
|
+
this.streams.delete(streamId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Cleans up all streams.
|
|
66
|
+
*/
|
|
67
|
+
clear() {
|
|
68
|
+
this.streams.clear();
|
|
69
|
+
this.eventIdToStreamId.clear();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -3,7 +3,7 @@ import { channel } from 'node:diagnostics_channel';
|
|
|
3
3
|
import { realpath } from 'node:fs/promises';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/abort.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getInitHandshakeTimeoutMs } from '../lib/constants.js';
|
|
7
7
|
import { formatUnknownErrorMessage } from '../lib/errors.js';
|
|
8
8
|
import { Logger, logToMcp } from '../lib/logger.js';
|
|
9
9
|
import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
|
|
@@ -128,6 +128,7 @@ export class RootsManager {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
registerHandlers(server, onInitTimeout) {
|
|
131
|
+
const initHandshakeTimeoutMs = getInitHandshakeTimeoutMs();
|
|
131
132
|
server.server.setNotificationHandler('notifications/initialized', async () => {
|
|
132
133
|
if (this.initTimer) {
|
|
133
134
|
clearTimeout(this.initTimer);
|
|
@@ -146,14 +147,14 @@ export class RootsManager {
|
|
|
146
147
|
if (LIFECYCLE_CHANNEL.hasSubscribers) {
|
|
147
148
|
LIFECYCLE_CHANNEL.publish({
|
|
148
149
|
phase: 'init_timeout',
|
|
149
|
-
timeoutMs:
|
|
150
|
+
timeoutMs: initHandshakeTimeoutMs,
|
|
150
151
|
});
|
|
151
152
|
}
|
|
152
|
-
logToMcp(server, 'warning', `Client did not send notifications/initialized within ${String(
|
|
153
|
+
logToMcp(server, 'warning', `Client did not send notifications/initialized within ${String(initHandshakeTimeoutMs)}ms`, this.loggingState.minimumLevel);
|
|
153
154
|
onInitTimeout?.();
|
|
154
155
|
}
|
|
155
156
|
this.initTimer = undefined;
|
|
156
|
-
},
|
|
157
|
+
}, initHandshakeTimeoutMs);
|
|
157
158
|
this.initTimer.unref();
|
|
158
159
|
}
|
|
159
160
|
async recomputeAllowedDirectories() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { InMemoryTaskStore, type Result, type Task } from '@modelcontextprotocol/server';
|
|
2
2
|
export declare class ResultAwareInMemoryTaskStore extends InMemoryTaskStore {
|
|
3
3
|
private readonly cancelledResults;
|
|
4
|
+
private evictExpired;
|
|
4
5
|
getTaskResult(taskId: string, sessionId?: string): Promise<Result>;
|
|
5
6
|
storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise<void>;
|
|
6
7
|
updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise<void>;
|