@j0hanz/filesystem-mcp 1.9.0 → 1.9.1

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 CHANGED
@@ -834,7 +834,7 @@ All 18 tools define `outputSchema` (Zod -> JSON Schema) and return `structuredCo
834
834
 
835
835
  | Variable | Default | Description |
836
836
  | ---------------------------------- | ---------------- | ------------------------------------------------------------------------------ |
837
- | `FILESYSTEM_MCP_API_KEY` | _(none)_ | Optional Bearer token for HTTP transport authentication |
837
+ | `FILESYSTEM_MCP_API_KEY` | _(none)_ | Bearer token required when binding HTTP to a non-loopback host |
838
838
  | `FILESYSTEM_MCP_MAX_HTTP_SESSIONS` | `100` | Max concurrent HTTP sessions (1-10,000) |
839
839
  | `FILESYSTEM_MCP_HTTP_HOST` | `127.0.0.1` | HTTP server bind address |
840
840
  | `FS_CONTEXT_MAX_REQUEST_BYTES` | `4194304` (4 MB) | Max HTTP request body size (1 KB - 256 MB) |
@@ -858,10 +858,10 @@ When started with `--port <number>`, the server exposes a single MCP endpoint:
858
858
 
859
859
  **Required headers:**
860
860
 
861
- - `mcp-protocol-version` — must match MCP v2025-11-25
861
+ - `mcp-protocol-version` — use the negotiated MCP protocol version on post-initialize HTTP requests
862
862
  - `mcp-session-id` — required for `GET`/`DELETE` (returned by `POST` on initialize)
863
863
 
864
- **Authentication:** If `FILESYSTEM_MCP_API_KEY` is set, requests must include `Authorization: Bearer <key>`. Uses SHA-256 timing-safe comparison.
864
+ **Authentication:** Requests to non-loopback HTTP binds require `FILESYSTEM_MCP_API_KEY`; clients must then send `Authorization: Bearer <key>`. Loopback-only binds may omit auth for local use. Uses SHA-256 timing-safe comparison.
865
865
 
866
866
  **CORS:** Only localhost origins allowed (`127.0.0.1`, `::1`, `localhost`).
867
867
 
@@ -873,11 +873,11 @@ When started with `--port <number>`, the server exposes a single MCP endpoint:
873
873
  | Traversal prevention | confirmed | `src/lib/paths.ts` — resolved paths checked after normalization |
874
874
  | Symlink escape prevention | confirmed | `src/__tests__/security.test.ts` — symlink boundary enforcement |
875
875
  | Sensitive file denylist | confirmed | `src/lib/constants.ts` — blocks `.git`, `.env*`, SSH keys, certs, secrets |
876
- | Origin validation | confirmed | `src/server/bootstrap.ts` — localhost-only CORS regex |
876
+ | Origin validation | confirmed | `src/server/bootstrap.ts` — localhost-only Origin allowlist |
877
877
  | Bearer auth | confirmed | `src/server/bootstrap.ts` — optional `FILESYSTEM_MCP_API_KEY` with timing-safe compare |
878
878
  | Input validation | confirmed | `src/schemas.ts` — Zod strict schemas on all tool inputs |
879
879
  | Request body limit | confirmed | `src/server/bootstrap.ts` — configurable max request size (413 on overflow) |
880
- | DNS rebinding protection | confirmed | `src/server/bootstrap.ts` — Host header validation |
880
+ | Remote bind guard | confirmed | `src/server/bootstrap.ts` — refuses non-loopback bind without `FILESYSTEM_MCP_API_KEY` |
881
881
 
882
882
  ## Development
883
883
 
@@ -19,12 +19,12 @@ declare const SearchOptionsSchema: z.ZodObject<{
19
19
  filePattern: z.ZodString;
20
20
  excludePatterns: z.ZodArray<z.ZodString>;
21
21
  caseSensitive: z.ZodBoolean;
22
- maxResults: z.ZodNumber;
23
- maxFileSize: z.ZodNumber;
24
- maxFilesScanned: z.ZodNumber;
25
- timeoutMs: z.ZodNumber;
22
+ maxResults: z.ZodInt;
23
+ maxFileSize: z.ZodInt;
24
+ maxFilesScanned: z.ZodInt;
25
+ timeoutMs: z.ZodInt;
26
26
  skipBinary: z.ZodBoolean;
27
- contextLines: z.ZodNumber;
27
+ contextLines: z.ZodInt;
28
28
  wholeWord: z.ZodBoolean;
29
29
  isLiteral: z.ZodBoolean;
30
30
  includeHidden: z.ZodBoolean;
@@ -88,12 +88,12 @@ const SearchOptionsSchema = z.strictObject({
88
88
  filePattern: z.string().min(1),
89
89
  excludePatterns: z.array(z.string()),
90
90
  caseSensitive: z.boolean(),
91
- maxResults: z.number().int().nonnegative(),
92
- maxFileSize: z.number().int().nonnegative(),
93
- maxFilesScanned: z.number().int().nonnegative(),
94
- timeoutMs: z.number().int().nonnegative(),
91
+ maxResults: z.int().min(0),
92
+ maxFileSize: z.int().min(0),
93
+ maxFilesScanned: z.int().min(0),
94
+ timeoutMs: z.int().min(0),
95
95
  skipBinary: z.boolean(),
96
- contextLines: z.number().int().nonnegative(),
96
+ contextLines: z.int().min(0),
97
97
  wholeWord: z.boolean(),
98
98
  isLiteral: z.boolean(),
99
99
  includeHidden: z.boolean(),
@@ -3,6 +3,10 @@ import { McpError } from './errors.js';
3
3
  export declare function toPosixPath(value: string): string;
4
4
  export declare function isSensitivePath(requestedPath: string, resolvedPath?: string): boolean;
5
5
  export declare function assertAllowedFileAccess(requestedPath: string, resolvedPath?: string): void;
6
+ export interface AllowedDirectoriesState {
7
+ primary: string[];
8
+ expanded: string[];
9
+ }
6
10
  /**
7
11
  * Normalizes any path-like input to an absolute path suitable for comparisons.
8
12
  * - Expands "~" home directory shorthand.
@@ -10,9 +14,13 @@ export declare function assertAllowedFileAccess(requestedPath: string, resolvedP
10
14
  * - Lowercases Windows drive letter for stable comparisons.
11
15
  */
12
16
  export declare function normalizePath(p: string): string;
17
+ export declare function withAllowedDirectoriesState<T>(state: AllowedDirectoriesState, run: () => T): T;
18
+ export declare function getAllowedDirectoriesState(): AllowedDirectoriesState;
19
+ export declare function setAllowedDirectoriesStateResolved(state: AllowedDirectoriesState): void;
13
20
  export declare function getAllowedDirectories(): string[];
14
21
  export declare function isAllowedDirectoryRoot(normalizedPath: string): boolean;
15
22
  export declare function isPathWithinDirectories(normalizedPath: string, allowedDirs: readonly string[]): boolean;
23
+ export declare function resolveAllowedDirectoriesState(dirs: readonly string[], signal?: AbortSignal): Promise<AllowedDirectoriesState>;
16
24
  export declare function setAllowedDirectoriesResolved(dirs: readonly string[], signal?: AbortSignal): Promise<void>;
17
25
  export declare function getReservedDeviceNameForPath(requestedPath: string): string | undefined;
18
26
  export declare function isWindowsDriveRelativePath(requestedPath: string): boolean;
package/dist/lib/paths.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
5
  import { platform } from 'node:os';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { SENSITIVE_FILE_ALLOWLIST, SENSITIVE_FILE_DENYLIST, } from './constants.js';
@@ -137,9 +138,18 @@ const RESERVED_DEVICE_NAMES = new Set([
137
138
  'LPT8',
138
139
  'LPT9',
139
140
  ]);
141
+ const allowedDirectoriesContext = new AsyncLocalStorage({
142
+ name: 'filesystem-mcp:allowed-directories',
143
+ });
140
144
  function dedupePreserveOrder(items) {
141
145
  return [...new Set(items)];
142
146
  }
147
+ function cloneAllowedDirectoriesState(state) {
148
+ return {
149
+ primary: [...state.primary],
150
+ expanded: [...state.expanded],
151
+ };
152
+ }
143
153
  function expandHome(filepath) {
144
154
  if (filepath === '~')
145
155
  return HOMEDIR;
@@ -218,26 +228,41 @@ function normalizeAllowedDirectories(dirs) {
218
228
  // single MCP session per process, so this is safe. In HTTP mode all HTTP
219
229
  // sessions within the same process share one policy — multi-tenant isolation
220
230
  // (different roots per session) requires separate server processes.
221
- let allowedDirectoriesExpanded = [];
222
- let allowedDirectoriesPrimary = [];
231
+ let defaultAllowedDirectoriesState = {
232
+ primary: [],
233
+ expanded: [],
234
+ };
223
235
  function setAllowedDirectoriesState(primary, expanded) {
224
- allowedDirectoriesPrimary = dedupePreserveOrder(primary);
225
- allowedDirectoriesExpanded = dedupePreserveOrder(expanded);
236
+ defaultAllowedDirectoriesState = {
237
+ primary: dedupePreserveOrder(primary),
238
+ expanded: dedupePreserveOrder(expanded),
239
+ };
240
+ }
241
+ function getActiveAllowedDirectoriesState() {
242
+ return allowedDirectoriesContext.getStore() ?? defaultAllowedDirectoriesState;
243
+ }
244
+ export function withAllowedDirectoriesState(state, run) {
245
+ return allowedDirectoriesContext.run(cloneAllowedDirectoriesState(state), run);
246
+ }
247
+ export function getAllowedDirectoriesState() {
248
+ return cloneAllowedDirectoriesState(getActiveAllowedDirectoriesState());
249
+ }
250
+ export function setAllowedDirectoriesStateResolved(state) {
251
+ setAllowedDirectoriesState(state.primary, state.expanded);
226
252
  }
227
253
  export function getAllowedDirectories() {
228
- return [...allowedDirectoriesExpanded];
254
+ return [...getActiveAllowedDirectoriesState().expanded];
229
255
  }
230
256
  export function isAllowedDirectoryRoot(normalizedPath) {
231
- for (const dir of allowedDirectoriesExpanded) {
257
+ for (const dir of getActiveAllowedDirectoriesState().expanded) {
232
258
  if (isSamePath(normalizedPath, dir))
233
259
  return true;
234
260
  }
235
261
  return false;
236
262
  }
237
263
  function getAllowedDirectoriesForRelativeResolution() {
238
- return allowedDirectoriesPrimary.length > 0
239
- ? allowedDirectoriesPrimary
240
- : allowedDirectoriesExpanded;
264
+ const state = getActiveAllowedDirectoriesState();
265
+ return state.primary.length > 0 ? state.primary : state.expanded;
241
266
  }
242
267
  function isPathInsideDirectory(normalizedDirectory, normalizedCandidate) {
243
268
  const root = normalizeForComparison(normalizedDirectory);
@@ -286,10 +311,14 @@ async function expandAllowedDirectories(primaryDirs, signal) {
286
311
  }
287
312
  return dedupePreserveOrder(expanded);
288
313
  }
289
- export async function setAllowedDirectoriesResolved(dirs, signal) {
314
+ export async function resolveAllowedDirectoriesState(dirs, signal) {
290
315
  const primary = normalizeAllowedDirectories(dirs);
291
316
  const expanded = await expandAllowedDirectories(primary, signal);
292
- setAllowedDirectoriesState(primary, expanded);
317
+ return { primary, expanded };
318
+ }
319
+ export async function setAllowedDirectoriesResolved(dirs, signal) {
320
+ const state = await resolveAllowedDirectoriesState(dirs, signal);
321
+ setAllowedDirectoriesStateResolved(state);
293
322
  }
294
323
  function ensureNonEmptyPath(requestedPath) {
295
324
  if (!requestedPath || requestedPath.trim().length === 0) {
package/dist/schemas.d.ts CHANGED
@@ -34,13 +34,13 @@ export declare const ToolErrorResponseSchema: z.ZodObject<{
34
34
  }, z.core.$strict>;
35
35
  }, z.core.$strict>;
36
36
  declare const HeadLinesSchema: z.ZodOptional<z.ZodInt>;
37
- declare const LineNumberSchema: z.ZodNumber;
37
+ declare const LineNumberSchema: z.ZodInt;
38
38
  export declare const ListDirectoryInputSchema: z.ZodObject<{
39
39
  path: z.ZodOptional<z.ZodString>;
40
40
  includeHidden: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
41
41
  includeIgnored: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
42
- maxDepth: z.ZodOptional<z.ZodNumber>;
43
- maxEntries: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
42
+ maxDepth: z.ZodOptional<z.ZodInt>;
43
+ maxEntries: z.ZodDefault<z.ZodOptional<z.ZodInt>>;
44
44
  sortBy: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
45
45
  name: "name";
46
46
  size: "size";
@@ -55,7 +55,7 @@ export declare const ListAllowedDirectoriesInputSchema: z.ZodObject<{}, z.core.$
55
55
  export declare const SearchFilesInputSchema: z.ZodObject<{
56
56
  path: z.ZodOptional<z.ZodString>;
57
57
  pattern: z.ZodString;
58
- maxResults: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
58
+ maxResults: z.ZodDefault<z.ZodOptional<z.ZodInt>>;
59
59
  includeIgnored: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
60
60
  includeHidden: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
61
61
  sortBy: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
@@ -64,13 +64,13 @@ export declare const SearchFilesInputSchema: z.ZodObject<{
64
64
  path: "path";
65
65
  modified: "modified";
66
66
  }>>>;
67
- maxDepth: z.ZodOptional<z.ZodNumber>;
67
+ maxDepth: z.ZodOptional<z.ZodInt>;
68
68
  cursor: z.ZodOptional<z.ZodString>;
69
69
  }, z.core.$strict>;
70
70
  export declare const TreeInputSchema: z.ZodObject<{
71
71
  path: z.ZodOptional<z.ZodString>;
72
- maxDepth: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
73
- maxEntries: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
72
+ maxDepth: z.ZodDefault<z.ZodOptional<z.ZodInt>>;
73
+ maxEntries: z.ZodDefault<z.ZodOptional<z.ZodInt>>;
74
74
  includeHidden: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
75
75
  includeIgnored: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
76
76
  }, z.core.$strict>;
@@ -80,8 +80,8 @@ export declare const SearchContentInputSchema: z.ZodObject<{
80
80
  isRegex: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
81
81
  caseSensitive: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
82
82
  wholeWord: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
83
- contextLines: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
84
- maxResults: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
83
+ contextLines: z.ZodDefault<z.ZodOptional<z.ZodInt>>;
84
+ maxResults: z.ZodDefault<z.ZodOptional<z.ZodInt>>;
85
85
  filePattern: z.ZodDefault<z.ZodOptional<z.ZodString>>;
86
86
  includeHidden: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
87
87
  includeIgnored: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
@@ -658,7 +658,7 @@ export declare const CalculateHashOutputSchema: z.ZodObject<{
658
658
  export declare const DiffFilesInputSchema: z.ZodObject<{
659
659
  original: z.ZodString;
660
660
  modified: z.ZodString;
661
- context: z.ZodOptional<z.ZodNumber>;
661
+ context: z.ZodOptional<z.ZodInt>;
662
662
  ignoreWhitespace: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
663
663
  stripTrailingCr: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
664
664
  }, z.core.$strict>;
@@ -691,7 +691,7 @@ export declare const DiffFilesOutputSchema: z.ZodObject<{
691
691
  export declare const ApplyPatchInputSchema: z.ZodObject<{
692
692
  path: z.ZodString;
693
693
  patch: z.ZodString;
694
- fuzzFactor: z.ZodOptional<z.ZodNumber>;
694
+ fuzzFactor: z.ZodOptional<z.ZodInt>;
695
695
  autoConvertLineEndings: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
696
696
  dryRun: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
697
697
  }, z.core.$strict>;
package/dist/schemas.js CHANGED
@@ -57,10 +57,7 @@ const HeadLinesSchema = z
57
57
  .max(100000, 'Max: 100,000')
58
58
  .optional()
59
59
  .describe('Read first N lines');
60
- const LineNumberSchema = z
61
- .number()
62
- .int({ error: 'Must be integer' })
63
- .min(1, 'Min: 1');
60
+ const LineNumberSchema = z.int({ error: 'Must be integer' }).min(1, 'Min: 1');
64
61
  function addReadRangeIssue(ctx, path, message) {
65
62
  ctx.addIssue({
66
63
  code: 'custom',
@@ -115,14 +112,12 @@ export const ListDirectoryInputSchema = z.strictObject({
115
112
  includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
116
113
  includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, .git, etc).'),
117
114
  maxDepth: z
118
- .number()
119
115
  .int({ error: 'Must be integer' })
120
116
  .min(1, 'Min: 1')
121
117
  .max(MAX_TREE_DEPTH, `Max: ${MAX_TREE_DEPTH}`)
122
118
  .optional()
123
119
  .describe('Max recursion depth when pattern is provided'),
124
120
  maxEntries: z
125
- .number()
126
121
  .int({ error: 'Must be integer' })
127
122
  .min(1, 'Min: 1')
128
123
  .max(MAX_LIST_ENTRIES, `Max: ${MAX_LIST_ENTRIES}`)
@@ -158,7 +153,6 @@ export const SearchFilesInputSchema = z.strictObject({
158
153
  })
159
154
  .describe('Glob pattern (e.g. "**/*.ts", "src/*.js")'),
160
155
  maxResults: z
161
- .number()
162
156
  .int({ error: 'Must be integer' })
163
157
  .min(1, 'Min: 1')
164
158
  .max(MAX_SEARCH_RESULTS, `Max: ${MAX_SEARCH_RESULTS}`)
@@ -171,7 +165,6 @@ export const SearchFilesInputSchema = z.strictObject({
171
165
  .default('path')
172
166
  .describe('Sort by path, name, size, or modified'),
173
167
  maxDepth: z
174
- .number()
175
168
  .int({ error: 'Must be integer' })
176
169
  .min(0, 'Min: 0')
177
170
  .max(MAX_SEARCH_DEPTH, `Max: ${MAX_SEARCH_DEPTH}`)
@@ -185,7 +178,6 @@ export const SearchFilesInputSchema = z.strictObject({
185
178
  export const TreeInputSchema = z.strictObject({
186
179
  path: OptionalPathSchema.describe(DESC_PATH_ROOT),
187
180
  maxDepth: z
188
- .number()
189
181
  .int({ error: 'Must be integer' })
190
182
  .min(0, 'Min: 0')
191
183
  .max(MAX_TREE_DEPTH, `Max: ${MAX_TREE_DEPTH}`)
@@ -193,7 +185,6 @@ export const TreeInputSchema = z.strictObject({
193
185
  .default(DEFAULT_TREE_DEPTH)
194
186
  .describe(`Depth (0=root node only, no children). Default: ${DEFAULT_TREE_DEPTH}`),
195
187
  maxEntries: z
196
- .number()
197
188
  .int({ error: 'Must be integer' })
198
189
  .min(1, 'Min: 1')
199
190
  .max(MAX_TREE_ENTRIES, `Max: ${MAX_TREE_ENTRIES}`)
@@ -214,7 +205,6 @@ export const SearchContentInputSchema = z.strictObject({
214
205
  caseSensitive: defaultFalseBoolean('Case-sensitive matching. Default: case-insensitive.'),
215
206
  wholeWord: defaultFalseBoolean('Match whole words only'),
216
207
  contextLines: z
217
- .number()
218
208
  .int({ error: 'Must be integer' })
219
209
  .min(0, 'Min: 0')
220
210
  .max(50, 'Max: 50')
@@ -222,7 +212,6 @@ export const SearchContentInputSchema = z.strictObject({
222
212
  .default(0)
223
213
  .describe('Include N lines of context before/after matches'),
224
214
  maxResults: z
225
- .number()
226
215
  .int({ error: 'Must be integer' })
227
216
  .min(0, 'Min: 0')
228
217
  .max(MAX_SEARCH_RESULTS, `Max: ${MAX_SEARCH_RESULTS}`)
@@ -434,7 +423,7 @@ export const CreateDirectoryInputSchema = z
434
423
  .describe('Absolute paths to directories to create'),
435
424
  })
436
425
  .refine((data) => data.path !== undefined || data.paths !== undefined, {
437
- message: "Either 'path' or 'paths' must be provided",
426
+ error: "Either 'path' or 'paths' must be provided",
438
427
  path: ['path'],
439
428
  });
440
429
  export const CreateDirectoryOutputSchema = z.strictObject({
@@ -490,7 +479,7 @@ export const MoveFileInputSchema = z
490
479
  destination: RequiredPathSchema.describe('New path'),
491
480
  })
492
481
  .refine((data) => (data.source ?? data.sources) !== undefined, {
493
- message: "Either 'source' or 'sources' must be provided",
482
+ error: "Either 'source' or 'sources' must be provided",
494
483
  path: ['source'],
495
484
  });
496
485
  export const MoveFileOutputSchema = z.strictObject({
@@ -535,7 +524,6 @@ export const DiffFilesInputSchema = z.strictObject({
535
524
  original: RequiredPathSchema.describe('Path to original file'),
536
525
  modified: RequiredPathSchema.describe('Path to modified file'),
537
526
  context: z
538
- .number()
539
527
  .int({ error: 'Must be integer' })
540
528
  .min(0, 'Min: 0')
541
529
  .max(10000, 'Max: 10,000')
@@ -566,7 +554,6 @@ export const ApplyPatchInputSchema = z.strictObject({
566
554
  .string()
567
555
  .describe('Unified diff with @@ hunk headers. Generate with `diff_files`.'),
568
556
  fuzzFactor: z
569
- .number()
570
557
  .int({ error: 'Must be integer' })
571
558
  .min(0, 'Min: 0')
572
559
  .max(20, 'Max: 20')
@@ -6,8 +6,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
8
  import { isInitializeRequest, SetLevelRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
9
- import { DEFAULT_LOG_LEVEL, parseEnvInt, REQUIRED_MCP_PROTOCOL_VERSION, } from '../lib/constants.js';
9
+ import { DEFAULT_LOG_LEVEL, parseEnvInt } from '../lib/constants.js';
10
10
  import { formatUnknownErrorMessage } from '../lib/errors.js';
11
+ import { withAllowedDirectoriesState } from '../lib/paths.js';
11
12
  import { createInMemoryResourceStore } from '../lib/resource-store.js';
12
13
  import { isRecord } from '../lib/utils.js';
13
14
  import { registerCompletions } from '../completions.js';
@@ -249,7 +250,7 @@ async function createHttpSession(options, sessions) {
249
250
  const transport = new StreamableHTTPServerTransport({
250
251
  sessionIdGenerator: () => randomUUID(),
251
252
  onsessioninitialized: (sessionId) => {
252
- sessions.set(sessionId, { server: mcpServer, transport });
253
+ sessions.set(sessionId, { server: mcpServer, rootsManager, transport });
253
254
  rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
254
255
  },
255
256
  });
@@ -264,7 +265,7 @@ async function createHttpSession(options, sessions) {
264
265
  });
265
266
  };
266
267
  await mcpServer.connect(transport);
267
- return { server: mcpServer, transport };
268
+ return { server: mcpServer, rootsManager, transport };
268
269
  }
269
270
  function sendJsonRpcError(res, status, code, message) {
270
271
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -280,32 +281,33 @@ function isAllowedOrigin(origin) {
280
281
  return true; // Non-browser clients omit Origin.
281
282
  return LOCALHOST_ORIGIN_RE.test(origin);
282
283
  }
283
- function getProtocolVersionHeader(req) {
284
- const rawProtocolVersion = req.headers['mcp-protocol-version'];
285
- if (typeof rawProtocolVersion === 'string') {
286
- return rawProtocolVersion;
287
- }
288
- if (Array.isArray(rawProtocolVersion)) {
289
- return rawProtocolVersion.find((value) => value === REQUIRED_MCP_PROTOCOL_VERSION);
290
- }
291
- return undefined;
292
- }
293
- function ensureProtocolVersionHeader(req, res) {
294
- const protocolVersion = getProtocolVersionHeader(req);
295
- if (protocolVersion === REQUIRED_MCP_PROTOCOL_VERSION) {
296
- return true;
297
- }
298
- sendJsonRpcError(res, 400, -32000, 'Bad Request: MCP-Protocol-Version header missing or unsupported');
299
- return false;
300
- }
301
284
  function discardRequestBody(req) {
302
285
  req.on('error', () => {
303
286
  // Best effort drain to avoid corrupting keep-alive pipelines.
304
287
  });
305
288
  req.resume();
306
289
  }
290
+ async function handleSessionTransportRequest(session, req, res, body) {
291
+ await withAllowedDirectoriesState(session.rootsManager.getAllowedDirectoriesState(), () => session.transport.handleRequest(req, res, body));
292
+ }
293
+ function isLoopbackHttpHost(host) {
294
+ const normalized = host.trim().toLowerCase();
295
+ return (normalized === '127.0.0.1' ||
296
+ normalized === 'localhost' ||
297
+ normalized === '::1' ||
298
+ normalized === '[::1]');
299
+ }
300
+ function assertHttpBindingSecurity(host) {
301
+ if (isLoopbackHttpHost(host))
302
+ return;
303
+ if (process.env['FILESYSTEM_MCP_API_KEY'])
304
+ return;
305
+ throw new Error(`Refusing to bind HTTP server to non-loopback host '${host}' without FILESYSTEM_MCP_API_KEY.`);
306
+ }
307
307
  export async function startHttpServer(port, options) {
308
308
  const sessions = new Map();
309
+ const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
310
+ assertHttpBindingSecurity(httpHost);
309
311
  async function handleMcpRequest(req, res) {
310
312
  const { method } = req;
311
313
  const MAX_SESSION_ID_LENGTH = 256;
@@ -354,14 +356,10 @@ export async function startHttpServer(port, options) {
354
356
  discardRequestBody(req);
355
357
  return;
356
358
  }
357
- if (!ensureProtocolVersionHeader(req, res)) {
358
- discardRequestBody(req);
359
- return;
360
- }
361
359
  const body = await readRequestBody(req);
362
360
  const session = sessions.get(sessionId);
363
361
  if (session) {
364
- await session.transport.handleRequest(req, res, body);
362
+ await handleSessionTransportRequest(session, req, res, body);
365
363
  }
366
364
  else {
367
365
  sendJsonRpcError(res, 404, -32000, 'Session not found');
@@ -375,8 +373,8 @@ export async function startHttpServer(port, options) {
375
373
  sendJsonRpcError(res, 503, -32000, 'Too many sessions');
376
374
  return;
377
375
  }
378
- const { transport } = await createHttpSession(options, sessions);
379
- await transport.handleRequest(req, res, body);
376
+ const session = await createHttpSession(options, sessions);
377
+ await handleSessionTransportRequest(session, req, res, body);
380
378
  return;
381
379
  }
382
380
  sendJsonRpcError(res, 400, -32000, 'Bad Request: No valid session ID provided');
@@ -391,12 +389,9 @@ export async function startHttpServer(port, options) {
391
389
  sendJsonRpcError(res, 404, -32000, 'Session not found');
392
390
  return;
393
391
  }
394
- if (!ensureProtocolVersionHeader(req, res)) {
395
- return;
396
- }
397
392
  const session = sessions.get(sessionId);
398
393
  if (session) {
399
- await session.transport.handleRequest(req, res);
394
+ await handleSessionTransportRequest(session, req, res);
400
395
  }
401
396
  else {
402
397
  sendJsonRpcError(res, 404, -32000, 'Session not found');
@@ -439,9 +434,6 @@ export async function startHttpServer(port, options) {
439
434
  res.end('Not Found');
440
435
  }
441
436
  });
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
437
  return new Promise((resolve, reject) => {
446
438
  httpServer.once('error', reject);
447
439
  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>;
@@ -3,7 +3,7 @@ import { InitializedNotificationSchema, RootsListChangedNotificationSchema, } fr
3
3
  import { z } from 'zod';
4
4
  import { formatUnknownErrorMessage } from '../lib/errors.js';
5
5
  import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
6
- import { getAllowedDirectories, getValidRootDirectories, isPathWithinDirectories, normalizePath, setAllowedDirectoriesResolved, } from '../lib/paths.js';
6
+ import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
7
7
  import { isRecord } from '../lib/utils.js';
8
8
  import { logToMcp } from './bootstrap.js';
9
9
  const ROOTS_TIMEOUT_MS = 5000;
@@ -83,6 +83,10 @@ async function filterRootsWithinBaseline(roots, baseline, signal) {
83
83
  export class RootsManager {
84
84
  rootsUpdateTimeout;
85
85
  rootDirectories = [];
86
+ allowedDirectoriesState = {
87
+ primary: [],
88
+ expanded: [],
89
+ };
86
90
  clientInitialized = false;
87
91
  // Set to true when an update is in progress, to prevent concurrent executions. If a change arrives while true, we queue a single retry after completion to ensure the last-known state is applied. This
88
92
  updatingRoots = false;
@@ -103,8 +107,14 @@ export class RootsManager {
103
107
  this.rootsUpdateTimeout = undefined;
104
108
  }
105
109
  }
110
+ getAllowedDirectoriesState() {
111
+ return {
112
+ primary: [...this.allowedDirectoriesState.primary],
113
+ expanded: [...this.allowedDirectoriesState.expanded],
114
+ };
115
+ }
106
116
  logMissingDirectoriesIfNeeded(server) {
107
- if (getAllowedDirectories().length === 0) {
117
+ if (this.allowedDirectoriesState.expanded.length === 0) {
108
118
  this.logMissingDirectories(server);
109
119
  }
110
120
  }
@@ -130,7 +140,9 @@ export class RootsManager {
130
140
  ? await filterRootsWithinBaseline(this.rootDirectories, baseline, signal)
131
141
  : this.rootDirectories;
132
142
  const combined = [...baseline, ...rootsToInclude];
133
- await setAllowedDirectoriesResolved(combined, signal);
143
+ const nextState = await resolveAllowedDirectoriesState(combined, signal);
144
+ this.allowedDirectoriesState = nextState;
145
+ setAllowedDirectoriesStateResolved(nextState);
134
146
  }
135
147
  finally {
136
148
  cleanup();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "mcpName": "io.github.j0hanz/filesystem-mcp",
5
5
  "description": "MCP Server that enables LLMs to interact with the local filesystem.",
6
6
  "type": "module",