Skip to content

Add Claude Code Filesystem MCP server#49

Open
matteo8p wants to merge 1 commit intoMercor-Intelligence:mainfrom
matteo8p:claude-code-filesystem-mcp-server
Open

Add Claude Code Filesystem MCP server#49
matteo8p wants to merge 1 commit intoMercor-Intelligence:mainfrom
matteo8p:claude-code-filesystem-mcp-server

Conversation

@matteo8p
Copy link
Copy Markdown

@matteo8p matteo8p commented Apr 24, 2026

Overview

In this PR, we add the MCP server that exposes Claude Code's bash/read/write tools from the sandbox environment. Tools that this MCP server exposes: bash, read, write, edit, monitor, glob, grep.

Keep in mind that Claude Code's filesystem tools are not public, and these MCP servers are approximations as to how they would work in a production environment. Read design doc below to learn more about tradeoffs.

Read the design doc for more context.

In the next PR, we will create the Claude Code agent that connects to this sandbox MCP server.

Testing

I did a manual and E2E test using MCPJam. I ensured that the MCP server properly exposes these MCP server tools, and that a test agent can properly use these tools to manipulate the test environment's filesystem.

Screenshot 2026-04-24 at 2 10 20 PM Screenshot 2026-04-24 at 2 09 23 PM Screenshot 2026-04-24 at 2 06 54 PM

"headers": null,
"transport": "stdio"
},
"claude_code_filesystem": {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ensures that our cc filesystem MCP server is spun up during testing.

from loguru import logger


class LoggingMiddleware(Middleware):
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied over from other MCP servers.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 77919eb. Configure here.

elif recursive:
for dirpath, _dirs, files in os.walk(root, followlinks=False):
real_dir = os.path.realpath(dirpath)
if not real_dir.startswith(real_fs_root):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing os.sep in sandbox path traversal check

High Severity

The sandbox boundary check real_dir.startswith(real_fs_root) is missing + os.sep, unlike the correct checks in glob.py (real_fs_root + os.sep) and path_utils.py (root + os.sep). This means a path like /filesystemx/... would incorrectly pass the check when real_fs_root is /filesystem, because Python's startswith matches the prefix without requiring a path separator boundary.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 77919eb. Configure here.


try:
await asyncio.wait_for(_read_lines(), timeout=timeout)
await proc.wait()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proc.wait() can hang indefinitely without timeout

High Severity

The await proc.wait() on line 63 is outside the asyncio.wait_for scope, so it has no timeout protection. If a process closes stdout (ending _read_lines) but continues running, proc.wait() blocks forever. This is especially likely for the long-running processes this tool is designed for (servers, build watchers).

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 77919eb. Configure here.

include: Annotated[
str,
Field(
description="Glob pattern to filter which files are searched. Default: '*' (all files). Example: '*.py', '*.{js,ts}'."
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grep include example uses unsupported brace expansion

Medium Severity

The include parameter description gives '*.{js,ts}' as an example, but fnmatch.fnmatch does not support brace expansion — curly braces are treated as literal characters. Using this documented example would silently match zero files, causing the grep to return no results even when matching files exist. The existing search_files tool in filesystem_server correctly avoids brace expansion examples in its description.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 77919eb. Configure here.

@matteo8p matteo8p changed the title Add Claude Code Filesystem MCP server [WIP] Add Claude Code Filesystem MCP server Apr 24, 2026
@matteo8p
Copy link
Copy Markdown
Author

matteo8p commented Apr 24, 2026

TODO: I was able to ask Claude what it's tool schemas are for all of the default tools.

Bash:

{
  "name": "Bash",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["command"],
    "properties": {
      "command": {
        "type": "string",
        "description": "The command to execute"
      },
      "description": {
        "type": "string",
        "description": "Clear, concise description of what this command does"
      },
      "timeout": {
        "type": "number",
        "description": "Optional timeout in milliseconds (max 600000)"
      },
      "run_in_background": {
        "type": "boolean",
        "description": "Run command in background; use Read on output file later"
      },
      "dangerouslyDisableSandbox": {
        "type": "boolean",
        "description": "Override sandbox mode and run without sandboxing"
      }
    }
  }
}

Edit

{
  "name": "Edit",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["file_path", "old_string", "new_string"],
    "properties": {
      "file_path": {
        "type": "string",
        "description": "The absolute path to the file to modify"
      },
      "old_string": {
        "type": "string",
        "description": "The text to replace (must be unique in the file)"
      },
      "new_string": {
        "type": "string",
        "description": "The text to replace it with (must differ from old_string)"
      },
      "replace_all": {
        "type": "boolean",
        "default": false,
        "description": "Replace all occurrences of old_string"
      }
    }
  }
}

Glob

{
  "name": "Glob",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["pattern"],
    "properties": {
      "pattern": {
        "type": "string",
        "description": "The glob pattern to match files against (e.g., '**/*.ts')"
      },
      "path": {
        "type": "string",
        "description": "The directory to search in (defaults to cwd)"
      }
    }
  }
}

Grep

{
  "name": "Grep",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["pattern"],
    "properties": {
      "pattern": {
        "type": "string",
        "description": "The regex pattern to search for"
      },
      "path": {
        "type": "string",
        "description": "File or directory to search in (defaults to cwd)"
      },
      "glob": {
        "type": "string",
        "description": "Glob pattern to filter files (e.g. '*.js')"
      },
      "type": {
        "type": "string",
        "description": "File type to search (e.g. 'js', 'py', 'rust')"
      },
      "output_mode": {
        "type": "string",
        "enum": ["content", "files_with_matches", "count"],
        "description": "Output mode (defaults to 'files_with_matches')"
      },
      "-i": {
        "type": "boolean",
        "description": "Case insensitive search"
      },
      "-n": {
        "type": "boolean",
        "description": "Show line numbers (requires output_mode: 'content')"
      },
      "-C": { "type": "number", "description": "Lines of context before and after match" },
      "-A": { "type": "number", "description": "Lines after match" },
      "-B": { "type": "number", "description": "Lines before match" },
      "context": { "type": "number", "description": "Alias for -C" },
      "multiline": {
        "type": "boolean",
        "default": false,
        "description": "Enable multiline mode (. matches newlines)"
      },
      "head_limit": {
        "type": "number",
        "default": 250,
        "description": "Limit output to first N lines/entries (0 = unlimited)"
      },
      "offset": {
        "type": "number",
        "default": 0,
        "description": "Skip first N entries before applying head_limit"
      }
    }
  }
}

Read

{
  "name": "Read",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["file_path"],
    "properties": {
      "file_path": {
        "type": "string",
        "description": "The absolute path to the file to read"
      },
      "limit": {
        "type": "integer",
        "exclusiveMinimum": 0,
        "description": "Number of lines to read (for large files)"
      },
      "offset": {
        "type": "integer",
        "minimum": 0,
        "description": "Line number to start reading from"
      },
      "pages": {
        "type": "string",
        "description": "Page range for PDFs (e.g. '1-5'). Max 20 pages per request."
      }
    }
  }
}

Write

{
  "name": "Write",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["file_path", "content"],
    "properties": {
      "file_path": {
        "type": "string",
        "description": "The absolute path to write to (must be absolute)"
      },
      "content": {
        "type": "string",
        "description": "The content to write to the file"
      }
    }
  }
}

Monitor

{
  "name": "Monitor",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["description", "timeout_ms", "persistent", "command"],
    "properties": {
      "command": {
        "type": "string",
        "description": "Shell command. Each stdout line is an event; exit ends the watch."
      },
      "description": {
        "type": "string",
        "description": "Human-readable description shown in notifications"
      },
      "persistent": {
        "type": "boolean",
        "default": false,
        "description": "Run for lifetime of session (no timeout). Stop with TaskStop."
      },
      "timeout_ms": {
        "type": "number",
        "default": 300000,
        "minimum": 1000,
        "description": "Kill monitor after this deadline (max 3600000ms). Ignored when persistent=true."
      }
    }
  }
}

@lucasrothman
Copy link
Copy Markdown

Looks great! Cursor comments are minor, do you think you could create a claude code agent that connects to it now?

@matteo8p
Copy link
Copy Markdown
Author

Looks great! Cursor comments are minor, do you think you could create a claude code agent that connects to it now?

I have that working in the next PR! Here's a screenshot of the Claude Code agent successfully connecting to the MCP server and using it. Wanted to merge PRs in increments with this one first. Would love your opinion and hope that works for you!
Screenshot 2026-04-24 at 4 13 03 PM

Screenshot 2026-04-24 at 4 13 18 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants