Skip to content

Commit 43aa97d

Browse files
milaforgefailuresmith
authored andcommitted
feat(filesystem): enforce read-only capability boundaries
1 parent f424458 commit 43aa97d

6 files changed

Lines changed: 602 additions & 125 deletions

File tree

src/filesystem/README.md

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
44

55
## Features
66

7-
- Read/write files
7+
- Read/write files (or run in **strict read-only** mode)
88
- Create/list/delete directories
99
- Move files/directories
1010
- Search files
@@ -30,6 +30,29 @@ Roots notified by Client to Server, completely replace any server-side Allowed d
3030

3131
This is the recommended method, as this enables runtime directory updates via `roots/list_changed` notifications without server restart, providing a more flexible and modern integration experience.
3232

33+
## Strict Read-Only Mode
34+
35+
Strict read-only mode enforces a server-side capability boundary.
36+
37+
When enabled, all write-capable tools are not registered, so only read and metadata tools are exposed. This goes beyond `readOnlyHint` annotations, which are advisory metadata for clients/models and not authorization controls.
38+
39+
If a client attempts to call a write tool in this mode, the request fails at the protocol layer with `METHOD_NOT_FOUND`.
40+
41+
Enable with either:
42+
43+
- CLI flag: `mcp-server-filesystem --read-only /path/to/dir`
44+
- Environment variable (case-insensitive): `READ_ONLY=true` (`1`, `true`, `yes`)
45+
46+
Safe defaults:
47+
48+
- `DEFAULT_READ_ONLY=1` starts the server in read-only mode unless explicitly overridden with `--write-enabled` or `READ_ONLY=false`.
49+
- Use `DEFAULT_READ_ONLY` to set a baseline in images/configuration; use `--write-enabled` for intentional maintenance runs without changing that baseline.
50+
- If a directory path begins with `--`, include a `--` separator (e.g., `mcp-server-filesystem -- /path/--looks-like-a-flag`).
51+
52+
When strict read-only is on, these tools are **not** available: `write_file`, `edit_file`, `create_directory`, `move_file`.
53+
54+
The server logs `Read-only mode enabled. Write operations will be disabled.` at startup for visibility.
55+
3356
### How It Works
3457

3558
The server's directory access control follows this flow:
@@ -43,20 +66,20 @@ The server's directory access control follows this flow:
4366
- Server checks if client supports roots protocol (`capabilities.roots`)
4467

4568
3. **Roots Protocol Handling** (if client supports roots)
46-
- **On initialization**: Server requests roots from client via `roots/list`
47-
- Client responds with its configured roots
48-
- Server replaces ALL allowed directories with client's roots
49-
- **On runtime updates**: Client can send `notifications/roots/list_changed`
50-
- Server requests updated roots and replaces allowed directories again
69+
- **On initialization**: Server requests roots from client via `roots/list`
70+
- Client responds with its configured roots
71+
- Server replaces all allowed directories with the client's roots
72+
- **On runtime updates**: Client can send `notifications/roots/list_changed`
73+
- Server requests updated roots and replaces allowed directories again
5174

5275
4. **Fallback Behavior** (if client doesn't support roots)
5376
- Server continues using command-line directories only
5477
- No dynamic updates possible
5578

5679
5. **Access Control**
57-
- All filesystem operations are restricted to allowed directories
58-
- Use `list_allowed_directories` tool to see current directories
59-
- Server requires at least ONE allowed directory to operate
80+
- All filesystem operations are restricted to allowed directories
81+
- Use `list_allowed_directories` tool to see current directories
82+
- Server requires at least one allowed directory to operate
6083

6184
**Note**: The server will only allow operations within directories specified either via `args` or via Roots.
6285

@@ -184,6 +207,8 @@ on each tool so clients can:
184207
- Understand which write operations are **idempotent** (safe to retry with the same arguments).
185208
- Highlight operations that may be **destructive** (overwriting or heavily mutating data).
186209

210+
These hints are client/model-facing metadata for planning and UX; they do not enforce permissions on the server. For server-side enforcement, use **Strict Read-Only Mode**.
211+
187212
The mapping for filesystem tools is:
188213

189214
| Tool | readOnlyHint | idempotentHint | destructiveHint | Notes |
@@ -210,7 +235,7 @@ Add this to your `claude_desktop_config.json`:
210235
Note: you can provide sandboxed directories to the server by mounting them to `/projects`. Adding the `ro` flag will make the directory readonly by the server.
211236

212237
### Docker
213-
Note: all directories must be mounted to `/projects` by default.
238+
Note: all directories must be mounted to `/projects` by default. The example below starts in read-only mode by default.
214239

215240
```json
216241
{
@@ -221,10 +246,12 @@ Note: all directories must be mounted to `/projects` by default.
221246
"run",
222247
"-i",
223248
"--rm",
249+
"-e", "DEFAULT_READ_ONLY=1",
224250
"--mount", "type=bind,src=/Users/username/Desktop,dst=/projects/Desktop",
225251
"--mount", "type=bind,src=/path/to/other/allowed/dir,dst=/projects/other/allowed/dir,ro",
226252
"--mount", "type=bind,src=/path/to/file.txt,dst=/projects/path/to/file.txt",
227253
"mcp/filesystem",
254+
"--read-only",
228255
"/projects"
229256
]
230257
}
@@ -242,6 +269,7 @@ Note: all directories must be mounted to `/projects` by default.
242269
"args": [
243270
"-y",
244271
"@modelcontextprotocol/server-filesystem",
272+
"--read-only",
245273
"/Users/username/Desktop",
246274
"/path/to/other/allowed/dir"
247275
]
@@ -284,6 +312,7 @@ Note: all directories must be mounted to `/projects` by default.
284312
"--rm",
285313
"--mount", "type=bind,src=${workspaceFolder},dst=/projects/workspace",
286314
"mcp/filesystem",
315+
"--read-only",
287316
"/projects"
288317
]
289318
}
@@ -301,13 +330,18 @@ Note: all directories must be mounted to `/projects` by default.
301330
"args": [
302331
"-y",
303332
"@modelcontextprotocol/server-filesystem",
333+
"--read-only",
304334
"${workspaceFolder}"
305335
]
306336
}
307337
}
308338
}
309339
```
310340

341+
## Release Notes
342+
343+
- **0.6.4** – Added strict read-only mode (`--read-only` flag or `READ_ONLY` env var) that omits all write-capable tools at registration time.
344+
311345
## Build
312346

313347
Docker build:
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { resolveReadOnlyMode, renderUsage } from '../mode-utils';
3+
4+
describe('resolveReadOnlyMode', () => {
5+
it('errors when both read-only and write-enabled flags are set', () => {
6+
const result = resolveReadOnlyMode(['--read-only', '--write-enabled'], {});
7+
expect(result.error).toBeDefined();
8+
});
9+
10+
it('warns on invalid env values and falls back to defaults', () => {
11+
const result = resolveReadOnlyMode([], { READ_ONLY: 'maybe', DEFAULT_READ_ONLY: 'sure' } as any);
12+
expect(result.warnings.length).toBeGreaterThan(0);
13+
expect(result.isReadOnly).toBe(false); // falls back to default false when env invalid
14+
});
15+
16+
it('applies precedence: write-enabled beats default read-only', () => {
17+
const result = resolveReadOnlyMode(['--write-enabled'], { DEFAULT_READ_ONLY: '1' } as any);
18+
expect(result.isReadOnly).toBe(false);
19+
});
20+
21+
it('uses READ_ONLY env before DEFAULT_READ_ONLY', () => {
22+
const result = resolveReadOnlyMode([], { READ_ONLY: '0', DEFAULT_READ_ONLY: '1' } as any);
23+
expect(result.isReadOnly).toBe(false);
24+
});
25+
26+
it('handles directory names after -- as literal paths', () => {
27+
const result = resolveReadOnlyMode(['--read-only', '--', '--looks-like-flag', '/data'], {});
28+
expect(result.directories).toEqual(['--looks-like-flag', '/data']);
29+
expect(result.isReadOnly).toBe(true);
30+
});
31+
32+
it('honors help flag', () => {
33+
const result = resolveReadOnlyMode(['--help'], {});
34+
expect(result.helpRequested).toBe(true);
35+
});
36+
});
37+
38+
describe('renderUsage', () => {
39+
it('mentions precedence order', () => {
40+
const text = renderUsage();
41+
expect(text.toLowerCase()).toContain('precedence');
42+
expect(text).toContain('--read-only');
43+
expect(text).toContain('--write-enabled');
44+
});
45+
});

0 commit comments

Comments
 (0)