diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index c2159b229f..0690b49f76 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -547,7 +547,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = "<0.28" }, + { name = "httpx", specifier = ">=0.27" }, { name = "markdownify", specifier = ">=0.13.1" }, { name = "mcp", specifier = ">=1.1.3" }, { name = "protego", specifier = ">=0.3.1" }, diff --git a/src/filesystem/README.md b/src/filesystem/README.md index bf087a2b25..90b05a5332 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -4,7 +4,7 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio ## Features -- Read/write files +- Read/write files (or run in **strict read-only** mode) - Create/list/delete directories - Move files/directories - Search files @@ -30,6 +30,29 @@ Roots notified by Client to Server, completely replace any server-side Allowed d 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. +## Strict Read-Only Mode + +Strict read-only mode enforces a server-side capability boundary. + +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. + +If a client attempts to call a write tool in this mode, the request fails at the protocol layer with `METHOD_NOT_FOUND`. + +Enable with either: + +- CLI flag: `mcp-server-filesystem --read-only /path/to/dir` +- Environment variable (case-insensitive): `READ_ONLY=true` (`1`, `true`, `yes`) + +Safe defaults: + +- `DEFAULT_READ_ONLY=1` starts the server in read-only mode unless explicitly overridden with `--write-enabled` or `READ_ONLY=false`. +- Use `DEFAULT_READ_ONLY` to set a baseline in images/configuration; use `--write-enabled` for intentional maintenance runs without changing that baseline. +- If a directory path begins with `--`, include a `--` separator (e.g., `mcp-server-filesystem -- /path/--looks-like-a-flag`). + +When strict read-only is on, these tools are **not** available: `write_file`, `edit_file`, `create_directory`, `move_file`. + +The server logs `Read-only mode enabled. Write operations will be disabled.` at startup for visibility. + ### How It Works The server's directory access control follows this flow: @@ -43,20 +66,20 @@ The server's directory access control follows this flow: - Server checks if client supports roots protocol (`capabilities.roots`) 3. **Roots Protocol Handling** (if client supports roots) - - **On initialization**: Server requests roots from client via `roots/list` - - Client responds with its configured roots - - Server replaces ALL allowed directories with client's roots - - **On runtime updates**: Client can send `notifications/roots/list_changed` - - Server requests updated roots and replaces allowed directories again + - **On initialization**: Server requests roots from client via `roots/list` + - Client responds with its configured roots + - Server replaces all allowed directories with the client's roots + - **On runtime updates**: Client can send `notifications/roots/list_changed` + - Server requests updated roots and replaces allowed directories again 4. **Fallback Behavior** (if client doesn't support roots) - Server continues using command-line directories only - No dynamic updates possible 5. **Access Control** - - All filesystem operations are restricted to allowed directories - - Use `list_allowed_directories` tool to see current directories - - Server requires at least ONE allowed directory to operate + - All filesystem operations are restricted to allowed directories + - Use `list_allowed_directories` tool to see current directories + - Server requires at least one allowed directory to operate **Note**: The server will only allow operations within directories specified either via `args` or via Roots. @@ -184,6 +207,8 @@ on each tool so clients can: - Understand which write operations are **idempotent** (safe to retry with the same arguments). - Highlight operations that may be **destructive** (overwriting or heavily mutating data). +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**. + The mapping for filesystem tools is: | Tool | readOnlyHint | idempotentHint | destructiveHint | Notes | @@ -210,7 +235,7 @@ Add this to your `claude_desktop_config.json`: 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. ### Docker -Note: all directories must be mounted to `/projects` by default. +Note: all directories must be mounted to `/projects` by default. The example below starts in read-only mode by default. ```json { @@ -221,10 +246,12 @@ Note: all directories must be mounted to `/projects` by default. "run", "-i", "--rm", + "-e", "DEFAULT_READ_ONLY=1", "--mount", "type=bind,src=/Users/username/Desktop,dst=/projects/Desktop", "--mount", "type=bind,src=/path/to/other/allowed/dir,dst=/projects/other/allowed/dir,ro", "--mount", "type=bind,src=/path/to/file.txt,dst=/projects/path/to/file.txt", "mcp/filesystem", + "--read-only", "/projects" ] } @@ -242,6 +269,7 @@ Note: all directories must be mounted to `/projects` by default. "args": [ "-y", "@modelcontextprotocol/server-filesystem", + "--read-only", "/Users/username/Desktop", "/path/to/other/allowed/dir" ] @@ -284,6 +312,7 @@ Note: all directories must be mounted to `/projects` by default. "--rm", "--mount", "type=bind,src=${workspaceFolder},dst=/projects/workspace", "mcp/filesystem", + "--read-only", "/projects" ] } @@ -301,6 +330,7 @@ Note: all directories must be mounted to `/projects` by default. "args": [ "-y", "@modelcontextprotocol/server-filesystem", + "--read-only", "${workspaceFolder}" ] } @@ -308,6 +338,10 @@ Note: all directories must be mounted to `/projects` by default. } ``` +## Release Notes + +- **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. + ## Build Docker build: diff --git a/src/filesystem/__tests__/mode-utils.test.ts b/src/filesystem/__tests__/mode-utils.test.ts new file mode 100644 index 0000000000..829894bf8f --- /dev/null +++ b/src/filesystem/__tests__/mode-utils.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { resolveReadOnlyMode, renderUsage } from '../mode-utils'; + +describe('resolveReadOnlyMode', () => { + it('errors when both read-only and write-enabled flags are set', () => { + const result = resolveReadOnlyMode(['--read-only', '--write-enabled'], {}); + expect(result.error).toBeDefined(); + }); + + it('warns on invalid env values and falls back to defaults', () => { + const result = resolveReadOnlyMode([], { READ_ONLY: 'maybe', DEFAULT_READ_ONLY: 'sure' } as any); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.isReadOnly).toBe(false); // falls back to default false when env invalid + }); + + it('applies precedence: write-enabled beats default read-only', () => { + const result = resolveReadOnlyMode(['--write-enabled'], { DEFAULT_READ_ONLY: '1' } as any); + expect(result.isReadOnly).toBe(false); + }); + + it('uses READ_ONLY env before DEFAULT_READ_ONLY', () => { + const result = resolveReadOnlyMode([], { READ_ONLY: '0', DEFAULT_READ_ONLY: '1' } as any); + expect(result.isReadOnly).toBe(false); + }); + + it('handles directory names after -- as literal paths', () => { + const result = resolveReadOnlyMode(['--read-only', '--', '--looks-like-flag', '/data'], {}); + expect(result.directories).toEqual(['--looks-like-flag', '/data']); + expect(result.isReadOnly).toBe(true); + }); + + it('honors help flag', () => { + const result = resolveReadOnlyMode(['--help'], {}); + expect(result.helpRequested).toBe(true); + }); +}); + +describe('renderUsage', () => { + it('mentions precedence order', () => { + const text = renderUsage(); + expect(text.toLowerCase()).toContain('precedence'); + expect(text).toContain('--read-only'); + expect(text).toContain('--write-enabled'); + }); +}); diff --git a/src/filesystem/__tests__/readonly-enforcement.test.ts b/src/filesystem/__tests__/readonly-enforcement.test.ts new file mode 100644 index 0000000000..df571c3539 --- /dev/null +++ b/src/filesystem/__tests__/readonly-enforcement.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; + +// Path to compiled server entry (kept in sync with other integration tests) +const SERVER_PATH = path.join(__dirname, '..', 'dist', 'index.js'); + +type ServerHandle = { + proc: ChildProcessWithoutNullStreams; + stdout: string; + stderr: string; + sendRequest: (method: string, params: any) => Promise; +}; + +async function spawnServer(args: string[], env: Record = {}, startupTimeoutMs = 4000): Promise { + const proc = spawn('node', [SERVER_PATH, ...args], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...env } + }); + + let stderr = ''; + let stdout = ''; + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + const sendRequest = (method: string, params: any) => { + const id = Math.floor(Math.random() * 10000); + const request = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params + }) + '\n'; + + return new Promise((resolve) => { + const handler = (data: Buffer) => { + const lines = data.toString().split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + try { + const response = JSON.parse(line); + if (response.id === id) { + proc.stdout?.removeListener('data', handler); + resolve(response); + return; + } + } catch (e) { + // Not a JSON line + } + } + }; + proc.stdout?.on('data', handler); + proc.stdin?.write(request); + }); + }; + + // Wait for server to be ready with a timeout to avoid hanging tests + await new Promise((resolve, reject) => { + const check = (data: Buffer) => { + if (data.toString().toLowerCase().includes('running on stdio')) { + clearTimeout(timeout); + proc.stderr?.off('data', check); + resolve(null); + } + }; + + const timeout = setTimeout(() => { + proc.stderr?.off('data', check); + reject(new Error('Server did not start within timeout')); + }, startupTimeoutMs); + + proc.stderr?.on('data', check); + }); + + return { + proc, + stdout, + stderr, + sendRequest + }; +} + +describe('Read-Only Mode Enforcement', () => { + let testDir: string; + let server: ServerHandle | undefined; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-readonly-test-')); + }); + + afterEach(async () => { + server?.proc.kill(); + server = undefined; + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('should allow write operations in default mode', async () => { + server = await spawnServer([testDir]); + + // Initializing + await server.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + }); + + const writeResponse = await server.sendRequest('tools/call', { + name: 'write_file', + arguments: { + path: path.join(testDir, 'test.txt'), + content: 'hello world' + } + }); + + expect(writeResponse.error).toBeUndefined(); + expect(writeResponse.result.content[0].text).toContain('Successfully wrote'); + + // Tool metadata should still advertise write capabilities with readOnlyHint false + const toolsResponse: any = await server.sendRequest('tools/list', {}); + const toolHints = Object.fromEntries( + toolsResponse.result.tools.map((t: any) => [t.name, t.annotations?.readOnlyHint]) + ); + + expect(toolHints['write_file']).toBe(false); + expect(toolHints['edit_file']).toBe(false); + expect(toolHints['create_directory']).toBe(false); + expect(toolHints['move_file']).toBe(false); + expect(toolHints['read_file']).toBe(true); + }); + + it('should block write operations in --read-only mode', async () => { + server = await spawnServer(['--read-only', testDir]); + + await server.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + }); + + const destructiveTools: { name: string, args: any }[] = [ + { + name: 'write_file', + args: { path: path.join(testDir, 'test.txt'), content: 'hello world' } + }, + { + name: 'edit_file', + args: { path: path.join(testDir, 'test.txt'), edits: [{ oldText: 'a', newText: 'b' }], dryRun: true } + }, + { + name: 'create_directory', + args: { path: path.join(testDir, 'new-dir') } + }, + { + name: 'move_file', + args: { source: path.join(testDir, 'a.txt'), destination: path.join(testDir, 'b.txt') } + } + ]; + + for (const { name, args } of destructiveTools) { + const response: any = await server.sendRequest('tools/call', { + name, + arguments: args + }); + + // Tool should be missing; SDK may surface this either as JSON-RPC error or as an MCP result error + if (response.error) { + expect(response.error.code).toBe(-32601); + expect(response.error.message.toLowerCase()).toContain(name); + } else { + expect(response.result?.isError).toBe(true); + const errorText = response.result?.content?.[0]?.text ?? ''; + expect(errorText.toLowerCase()).toContain(name); + } + } + + // Check tool list omits destructive tools but keeps reads + const toolsResponse: any = await server.sendRequest('tools/list', {}); + const toolNames = toolsResponse.result.tools.map((t: any) => t.name); + + expect(toolNames).not.toContain('write_file'); + expect(toolNames).not.toContain('edit_file'); + expect(toolNames).not.toContain('create_directory'); + expect(toolNames).not.toContain('move_file'); + expect(toolNames).toContain('read_file'); + + }); + +it('should block write operations via environment variable', async () => { + server = await spawnServer([testDir], { READ_ONLY: 'true' }); + + await server.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + }); + + const toolsResponse: any = await server.sendRequest('tools/list', {}); + const toolNames = toolsResponse.result.tools.map((t: any) => t.name); + + expect(toolNames).not.toContain('write_file'); +}); + + it('should default to read-only when DEFAULT_READ_ONLY is set', async () => { + server = await spawnServer([testDir], { DEFAULT_READ_ONLY: 'yes' }); + + await server.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + }); + + const toolsResponse: any = await server.sendRequest('tools/list', {}); + const toolNames = toolsResponse.result.tools.map((t: any) => t.name); + + expect(toolNames).not.toContain('write_file'); + }); + + it('should allow overriding DEFAULT_READ_ONLY with --write-enabled', async () => { + server = await spawnServer(['--write-enabled', testDir], { DEFAULT_READ_ONLY: '1' }); + + await server.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + }); + + const toolsResponse: any = await server.sendRequest('tools/list', {}); + const toolNames = toolsResponse.result.tools.map((t: any) => t.name); + + expect(toolNames).toContain('write_file'); + }); + + it('should allow read operations while in --read-only mode', async () => { + await fs.writeFile(path.join(testDir, 'readme.txt'), 'hello read-only'); + + server = await spawnServer(['--read-only', testDir]); + + await server.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + }); + + const readResponse: any = await server.sendRequest('tools/call', { + name: 'read_file', + arguments: { path: path.join(testDir, 'readme.txt') } + }); + + expect(readResponse.error).toBeUndefined(); + const text = readResponse.result?.content?.[0]?.text ?? ''; + expect(text).toContain('hello read-only'); + }); +}); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..726f8617cf 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { minimatch } from "minimatch"; import { normalizePath, expandHome } from './path-utils.js'; import { getValidRootDirectories } from './roots-utils.js'; +import { resolveReadOnlyMode, renderUsage } from './mode-utils.js'; import { // Function imports formatSize, @@ -30,12 +31,35 @@ import { // Command line argument parsing const args = process.argv.slice(2); -if (args.length === 0) { - console.error("Usage: mcp-server-filesystem [allowed-directory] [additional-directories...]"); - console.error("Note: Allowed directories can be provided via:"); - console.error(" 1. Command-line arguments (shown above)"); - console.error(" 2. MCP roots protocol (if client supports it)"); - console.error("At least one directory must be provided by EITHER method for the server to operate."); +const resolution = resolveReadOnlyMode(args, process.env); + +if (resolution.warnings.length > 0) { + for (const warning of resolution.warnings) { + console.error(`Warning: ${warning}`); + } +} + +if (resolution.error) { + console.error(`Error: ${resolution.error}`); + console.error(renderUsage()); + process.exit(1); +} + +if (resolution.helpRequested) { + console.error(renderUsage()); + process.exit(0); +} + +const isReadOnly = resolution.isReadOnly; +const directoryArgs = resolution.directories; + +if (directoryArgs.length === 0) { + console.error(renderUsage()); + console.error("At least one directory must be provided by command-line args or via MCP roots."); +} + +if (isReadOnly) { + console.error("Read-only mode enabled. Write operations will be disabled."); } // Store allowed directories in normalized and resolved form @@ -43,7 +67,7 @@ if (args.length === 0) { // This fixes the macOS /tmp -> /private/tmp symlink issue where users specify /tmp // but the resolved path is /private/tmp let allowedDirectories = (await Promise.all( - args.map(async (dir) => { + directoryArgs.map(async (dir) => { const expanded = expandHome(dir); const absolute = path.resolve(expanded); const normalizedOriginal = normalizePath(absolute); @@ -336,86 +360,88 @@ server.registerTool( } ); -server.registerTool( - "write_file", - { - title: "Write File", - description: - "Create a new file or completely overwrite an existing file with new content. " + - "Use with caution as it will overwrite existing files without warning. " + - "Handles text content with proper encoding. Only works within allowed directories.", - inputSchema: { - path: z.string(), - content: z.string() +if (!isReadOnly) { + server.registerTool( + "write_file", + { + title: "Write File", + description: + "Create a new file or completely overwrite an existing file with new content. " + + "Use with caution as it will overwrite existing files without warning. " + + "Handles text content with proper encoding. Only works within allowed directories.", + inputSchema: { + path: z.string(), + content: z.string() + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true } }, - outputSchema: { content: z.string() }, - annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true } - }, - async (args: z.infer) => { - const validPath = await validatePath(args.path); - await writeFileContent(validPath, args.content); - const text = `Successfully wrote to ${args.path}`; - return { - content: [{ type: "text" as const, text }], - structuredContent: { content: text } - }; - } -); - -server.registerTool( - "edit_file", - { - title: "Edit File", - description: - "Make line-based edits to a text file. Each edit replaces exact line sequences " + - "with new content. Returns a git-style diff showing the changes made. " + - "Only works within allowed directories.", - inputSchema: { - path: z.string(), - edits: z.array(z.object({ - oldText: z.string().describe("Text to search for - must match exactly"), - newText: z.string().describe("Text to replace with") - })), - dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format") + async (args: z.infer) => { + const validPath = await validatePath(args.path); + await writeFileContent(validPath, args.content); + const text = `Successfully wrote to ${args.path}`; + return { + content: [{ type: "text" as const, text }], + structuredContent: { content: text } + }; + } + ); + + server.registerTool( + "edit_file", + { + title: "Edit File", + description: + "Make line-based edits to a text file. Each edit replaces exact line sequences " + + "with new content. Returns a git-style diff showing the changes made. " + + "Only works within allowed directories.", + inputSchema: { + path: z.string(), + edits: z.array(z.object({ + oldText: z.string().describe("Text to search for - must match exactly"), + newText: z.string().describe("Text to replace with") + })), + dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format") + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } }, - outputSchema: { content: z.string() }, - annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } - }, - async (args: z.infer) => { - const validPath = await validatePath(args.path); - const result = await applyFileEdits(validPath, args.edits, args.dryRun); - return { - content: [{ type: "text" as const, text: result }], - structuredContent: { content: result } - }; - } -); - -server.registerTool( - "create_directory", - { - title: "Create Directory", - description: - "Create a new directory or ensure a directory exists. Can create multiple " + - "nested directories in one operation. If the directory already exists, " + - "this operation will succeed silently. Perfect for setting up directory " + - "structures for projects or ensuring required paths exist. Only works within allowed directories.", - inputSchema: { - path: z.string() + async (args: z.infer) => { + const validPath = await validatePath(args.path); + const result = await applyFileEdits(validPath, args.edits, args.dryRun); + return { + content: [{ type: "text" as const, text: result }], + structuredContent: { content: result } + }; + } + ); + + server.registerTool( + "create_directory", + { + title: "Create Directory", + description: + "Create a new directory or ensure a directory exists. Can create multiple " + + "nested directories in one operation. If the directory already exists, " + + "this operation will succeed silently. Perfect for setting up directory " + + "structures for projects or ensuring required paths exist. Only works within allowed directories.", + inputSchema: { + path: z.string() + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false } }, - outputSchema: { content: z.string() }, - annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false } - }, - async (args: z.infer) => { - const validPath = await validatePath(args.path); - await fs.mkdir(validPath, { recursive: true }); - const text = `Successfully created directory ${args.path}`; - return { - content: [{ type: "text" as const, text }], - structuredContent: { content: text } - }; - } -); + async (args: z.infer) => { + const validPath = await validatePath(args.path); + await fs.mkdir(validPath, { recursive: true }); + const text = `Successfully created directory ${args.path}`; + return { + content: [{ type: "text" as const, text }], + structuredContent: { content: text } + }; + } + ); +} server.registerTool( "list_directory", @@ -499,8 +525,7 @@ server.registerTool( // Format the output const formattedEntries = sortedEntries.map(entry => - `${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${ - entry.isDirectory ? "" : formatSize(entry.size).padStart(10) + `${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${entry.isDirectory ? "" : formatSize(entry.size).padStart(10) }` ); @@ -594,34 +619,36 @@ server.registerTool( } ); -server.registerTool( - "move_file", - { - title: "Move File", - description: - "Move or rename files and directories. Can move files between directories " + - "and rename them in a single operation. If the destination exists, the " + - "operation will fail. Works across different directories and can be used " + - "for simple renaming within the same directory. Both source and destination must be within allowed directories.", - inputSchema: { - source: z.string(), - destination: z.string() +if (!isReadOnly) { + server.registerTool( + "move_file", + { + title: "Move File", + description: + "Move or rename files and directories. Can move files between directories " + + "and rename them in a single operation. If the destination exists, the " + + "operation will fail. Works across different directories and can be used " + + "for simple renaming within the same directory. Both source and destination must be within allowed directories.", + inputSchema: { + source: z.string(), + destination: z.string() + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } }, - outputSchema: { content: z.string() }, - annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true } - }, - async (args: z.infer) => { - const validSourcePath = await validatePath(args.source); - const validDestPath = await validatePath(args.destination); - await fs.rename(validSourcePath, validDestPath); - const text = `Successfully moved ${args.source} to ${args.destination}`; - const contentBlock = { type: "text" as const, text }; - return { - content: [contentBlock], - structuredContent: { content: text } - }; - } -); + async (args: z.infer) => { + const validSourcePath = await validatePath(args.source); + const validDestPath = await validatePath(args.destination); + await fs.rename(validSourcePath, validDestPath); + const text = `Successfully moved ${args.source} to ${args.destination}`; + const contentBlock = { type: "text" as const, text }; + return { + content: [contentBlock], + structuredContent: { content: text } + }; + } + ); +} server.registerTool( "search_files", @@ -745,7 +772,7 @@ server.server.oninitialized = async () => { } else { if (allowedDirectories.length > 0) { console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories); - }else{ + } else { throw new Error(`Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.`); } } diff --git a/src/filesystem/mode-utils.ts b/src/filesystem/mode-utils.ts new file mode 100644 index 0000000000..f91e4d3160 --- /dev/null +++ b/src/filesystem/mode-utils.ts @@ -0,0 +1,110 @@ +export type ReadOnlyResolution = { + isReadOnly: boolean; + directories: string[]; + helpRequested: boolean; + warnings: string[]; + error?: string; +}; + +type BoolParse = { value: boolean | undefined; warning?: string }; + +const TRUE_VALUES = ["1", "true", "yes"]; +const FALSE_VALUES = ["0", "false", "no"]; + +function parseBoolEnvVar(name: string, env: NodeJS.ProcessEnv): BoolParse { + const raw = env[name]; + if (raw === undefined) return { value: undefined }; + const normalized = raw.toLowerCase(); + if (TRUE_VALUES.includes(normalized)) return { value: true }; + if (FALSE_VALUES.includes(normalized)) return { value: false }; + return { value: undefined, warning: `Ignoring ${name} because value '${raw}' is not one of ${[...TRUE_VALUES, ...FALSE_VALUES].join(', ')}` }; +} + +type ParsedArgs = { + helpRequested: boolean; + hasReadOnlyFlag: boolean; + hasWriteEnabledFlag: boolean; + directories: string[]; +}; + +function parseArgs(args: string[]): ParsedArgs { + const directories: string[] = []; + let helpRequested = false; + let hasReadOnlyFlag = false; + let hasWriteEnabledFlag = false; + let parsingFlags = true; + + for (const arg of args) { + if (parsingFlags) { + if (arg === "--help" || arg === "-h") { + helpRequested = true; + continue; + } + if (arg === "--read-only") { + hasReadOnlyFlag = true; + continue; + } + if (arg === "--write-enabled") { + hasWriteEnabledFlag = true; + continue; + } + if (arg === "--") { + parsingFlags = false; + continue; + } + } + // Either flags are done, or this argument isn't a known flag + directories.push(arg); + } + + return { helpRequested, hasReadOnlyFlag, hasWriteEnabledFlag, directories }; +} + +export function resolveReadOnlyMode(args: string[], env: NodeJS.ProcessEnv): ReadOnlyResolution { + const { helpRequested, hasReadOnlyFlag, hasWriteEnabledFlag, directories } = parseArgs(args); + const warnings: string[] = []; + + const readOnlyEnv = parseBoolEnvVar("READ_ONLY", env); + const defaultReadOnlyEnv = parseBoolEnvVar("DEFAULT_READ_ONLY", env); + if (readOnlyEnv.warning) warnings.push(readOnlyEnv.warning); + if (defaultReadOnlyEnv.warning) warnings.push(defaultReadOnlyEnv.warning); + + if (hasReadOnlyFlag && hasWriteEnabledFlag) { + return { + isReadOnly: false, + directories, + helpRequested, + warnings, + error: "Cannot specify both --read-only and --write-enabled" + }; + } + + const isReadOnly = hasWriteEnabledFlag + ? false + : hasReadOnlyFlag + ? true + : readOnlyEnv.value !== undefined + ? readOnlyEnv.value + : defaultReadOnlyEnv.value ?? false; + + return { isReadOnly, directories, helpRequested, warnings }; +} + +export function renderUsage(): string { + return [ + "Usage: mcp-server-filesystem [--read-only|--write-enabled] [--] [allowed-directory] [additional-directories...]", + "\nModes and precedence (highest wins):", + " 1. --write-enabled (force writes on)", + " 2. --read-only (force writes off)", + " 3. READ_ONLY env (1/true/yes or 0/false/no)", + " 4. DEFAULT_READ_ONLY env (1/true/yes to default to read-only)", + "\nFlags:", + " --read-only Disable all write tools", + " --write-enabled Explicitly enable write tools even if defaults say read-only", + " --help Show this message", + " -- Treat all following arguments as directory paths (even if they start with --)", + "\nEnvironment:", + " READ_ONLY Overrides everything except command-line flags", + " DEFAULT_READ_ONLY Baseline default (read-only) unless overridden", + ].join('\n'); +} diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 97357d90f4..aa2726baa5 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-filesystem", - "version": "0.6.3", + "version": "0.6.4", "description": "MCP server for filesystem access", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-filesystem",