@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.
@@ -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
- const ENUM_ARGUMENT_VALUES = new Map([
50
- ['sortby', ['modified', 'name', 'path', 'size', 'type']],
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 = ENUM_ARGUMENT_VALUES.get(argName);
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
- if (!isPathWithinDirectories(resolved, allowed))
271
- return undefined;
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 stat(resolved);
274
- if (stats.isDirectory())
275
- return resolved;
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
- // Fall back to parent path best-effort resolution.
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 (!isPathWithinDirectories(searchDir, allowed)) {
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
  }
@@ -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 const INIT_HANDSHAKE_TIMEOUT_MS: number;
8
+ export declare function getInitHandshakeTimeoutMs(): number;
9
9
  export declare const INIT_TIMEOUT_CLOSE: boolean;
10
- export declare const TASK_POLL_INTERVAL_MS = 100;
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;
@@ -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 const INIT_HANDSHAKE_TIMEOUT_MS = parseEnvInt('FS_INIT_HANDSHAKE_TIMEOUT_MS', 30_000, 1_000, 300_000);
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 = 100;
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;
@@ -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;
@@ -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) => {
@@ -1,4 +1,4 @@
1
- import type { Stats } from 'node:fs';
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;
@@ -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
- const content = await headFile(handle, head, options.encoding, options.maxSize, options.signal);
331
- const linesRead = countLines(content);
332
- const hasMoreLines = linesRead >= head;
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
  ],
@@ -1,10 +1,11 @@
1
1
  import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
2
- import { InMemoryTaskMessageQueue, isInitializeRequest, LATEST_PROTOCOL_VERSION, localhostAllowedHostnames, McpServer, StdioServerTransport, SUPPORTED_PROTOCOL_VERSIONS, validateHostHeader, } from '@modelcontextprotocol/server';
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 { DEFAULT_LOG_LEVEL, INIT_HANDSHAKE_TIMEOUT_MS, INIT_TIMEOUT_CLOSE, parseEnvInt, } from '../lib/constants.js';
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 (!data)
49
+ if (data === undefined)
48
50
  return '';
49
- return ` ${typeof data === 'string' ? data : JSON.stringify(data)}`;
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, negotiatedProtocolVersion) {
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, resolveNegotiatedProtocolVersion(body.params.protocolVersion));
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 SWEEP_INTERVAL_MS = INIT_HANDSHAKE_TIMEOUT_MS * 2;
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 > INIT_HANDSHAKE_TIMEOUT_MS) {
525
+ now - session.createdAt > initHandshakeTimeoutMs) {
542
526
  Logger.warn(`[HTTP] Evicting stale session ${sessionId}: client never sent notifications/initialized`);
543
- session.server.close().catch((err) => {
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 { INIT_HANDSHAKE_TIMEOUT_MS } from '../lib/constants.js';
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: INIT_HANDSHAKE_TIMEOUT_MS,
150
+ timeoutMs: initHandshakeTimeoutMs,
150
151
  });
151
152
  }
152
- logToMcp(server, 'warning', `Client did not send notifications/initialized within ${String(INIT_HANDSHAKE_TIMEOUT_MS)}ms`, this.loggingState.minimumLevel);
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
- }, INIT_HANDSHAKE_TIMEOUT_MS);
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>;