@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 +5 -5
- package/dist/lib/file-operations/search.d.ts +5 -5
- package/dist/lib/file-operations/search.js +5 -5
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +40 -11
- package/dist/schemas.d.ts +11 -11
- package/dist/schemas.js +3 -16
- package/dist/server/bootstrap.js +27 -35
- package/dist/server/roots-manager.d.ts +3 -0
- package/dist/server/roots-manager.js +15 -3
- package/package.json +1 -1
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)_ |
|
|
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` —
|
|
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:**
|
|
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
|
|
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
|
-
|
|
|
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.
|
|
23
|
-
maxFileSize: z.
|
|
24
|
-
maxFilesScanned: z.
|
|
25
|
-
timeoutMs: z.
|
|
22
|
+
maxResults: z.ZodInt;
|
|
23
|
+
maxFileSize: z.ZodInt;
|
|
24
|
+
maxFilesScanned: z.ZodInt;
|
|
25
|
+
timeoutMs: z.ZodInt;
|
|
26
26
|
skipBinary: z.ZodBoolean;
|
|
27
|
-
contextLines: z.
|
|
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.
|
|
92
|
-
maxFileSize: z.
|
|
93
|
-
maxFilesScanned: z.
|
|
94
|
-
timeoutMs: z.
|
|
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.
|
|
96
|
+
contextLines: z.int().min(0),
|
|
97
97
|
wholeWord: z.boolean(),
|
|
98
98
|
isLiteral: z.boolean(),
|
|
99
99
|
includeHidden: z.boolean(),
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -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
|
|
222
|
-
|
|
231
|
+
let defaultAllowedDirectoriesState = {
|
|
232
|
+
primary: [],
|
|
233
|
+
expanded: [],
|
|
234
|
+
};
|
|
223
235
|
function setAllowedDirectoriesState(primary, expanded) {
|
|
224
|
-
|
|
225
|
-
|
|
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 [...
|
|
254
|
+
return [...getActiveAllowedDirectoriesState().expanded];
|
|
229
255
|
}
|
|
230
256
|
export function isAllowedDirectoryRoot(normalizedPath) {
|
|
231
|
-
for (const dir of
|
|
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
|
-
|
|
239
|
-
|
|
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
|
|
314
|
+
export async function resolveAllowedDirectoriesState(dirs, signal) {
|
|
290
315
|
const primary = normalizeAllowedDirectories(dirs);
|
|
291
316
|
const expanded = await expandAllowedDirectories(primary, signal);
|
|
292
|
-
|
|
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.
|
|
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.
|
|
43
|
-
maxEntries: z.ZodDefault<z.ZodOptional<z.
|
|
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.
|
|
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.
|
|
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.
|
|
73
|
-
maxEntries: z.ZodDefault<z.ZodOptional<z.
|
|
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.
|
|
84
|
-
maxResults: z.ZodDefault<z.ZodOptional<z.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
379
|
-
await
|
|
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
|
|
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 {
|
|
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 (
|
|
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
|
|
143
|
+
const nextState = await resolveAllowedDirectoriesState(combined, signal);
|
|
144
|
+
this.allowedDirectoriesState = nextState;
|
|
145
|
+
setAllowedDirectoriesStateResolved(nextState);
|
|
134
146
|
}
|
|
135
147
|
finally {
|
|
136
148
|
cleanup();
|