19#include <nlohmann/json.hpp>
30namespace fs = std::filesystem;
31using json = nlohmann::json;
57 const std::string& path,
60 logger->info(
"Tracked read: session='{}' {}",
session, path);
80 const std::string& path)
const {
82 return reads !=
nullptr &&
reads->count(path) > 0;
147bool path_within(
const fs::path& path,
const fs::path& base) {
148 fs::path
rel = path.lexically_relative(base);
150 && *
rel.begin() != fs::path(
"..")
151 &&
rel != fs::path(
"..");
164 auto c = fs::weakly_canonical(entry,
ec);
165 return ec ? entry.lexically_normal() :
c;
182 const std::vector<fs::path>& entries,
const fs::path& resolved) {
183 std::optional<fs::path>
hit;
184 for (
const auto&
e : entries) {
202 const char* name =
"optional";
234 const OutsideRootRequest&
req) {
235 std::string
head =
"Path escapes project root: " +
req.path
236 +
" (root: " +
req.root +
")";
238 return "outside_root_rejected: " +
head +
" — the host's path "
241 return "outside_root_approval_required: " +
head +
" — "
243 "needs approval (mcp.filesystem.allow_outside_root: optional) and "
244 "no path approver is registered, so it is refused. Pre-approve "
245 "the directory with mcp.filesystem.outside_root_allow, set "
246 "allow_outside_root: true, or register an approver with "
247 "entropic_set_path_approval_callback.";
262 const fs::path& root) {
263 logger->error(
"Path escape blocked by outside_root_deny: {} {} "
265 resolved.string(), entry.string(), root.string());
266 throw std::runtime_error(
267 "outside_root_denied: " + resolved.string() +
" is under "
268 "mcp.filesystem.outside_root_deny entry " + entry.string()
269 +
" — refused regardless of allow_outside_root.");
285 const fs::path& root) {
286 logger->error(
"Path escape blocked: {} (root: {})",
287 resolved.string(), root.string());
288 throw std::runtime_error(
289 "Path escapes project root: " + resolved.string());
304 ?
"outside_root_allow entry " +
allowed_by->string()
305 : std::string(
"allow_outside_root: true");
306 logger->info(
"Outside-root {} served ({}): {}",
access_verb(access),
307 why, resolved.string());
319 for (
const auto&
p :
paths) {
320 s += (
s.empty() ?
"" :
", ") +
p.string();
322 return "[" +
s +
"]";
338 const fs::path& root) {
339 for (
const auto&
e : deny) {
341 logger->warn(
"outside_root_deny entry {} is INSIDE the root {} "
342 "— the outside-root lists govern paths outside "
343 "the root only, so it has no effect there",
344 e.string(), root.string());
375 &&
budget.visited >=
static_cast<long>(
budget.max_entries);
392 std::ifstream
in(path, std::ios::binary);
394 throw std::runtime_error(
395 "Cannot open file: " + path.string());
397 std::ostringstream
ss;
410 const std::string& content) {
411 fs::create_directories(path.parent_path());
412 std::ofstream out(path, std::ios::binary | std::ios::trunc);
413 if (!out.is_open()) {
414 throw std::runtime_error(
415 "Cannot write file: " + path.string());
428 return std::hash<std::string>{}(
s);
481 page.offset = args.value<
long>(
"offset", 0);
488 "offset must be >= 0 and limit 1.." + std::to_string(
max_limit)
489 +
" (got offset " + std::to_string(
page.offset) +
", limit "
490 + std::to_string(
page.limit) +
")");
513 explicit PagedResult(Page
page) : page_(
page) {}
525 if (page_.offset > seen_) {
527 }
else if (
static_cast<long>(items_.size()) >= page_.limit) {
531 items_.push_back(std::move(
item));
542 bool full()
const {
return more_; }
552 void finish(json& result,
const std::string& key,
554 const long returned =
static_cast<long>(items_.size());
555 result[key] = std::move(items_);
558 result[
"reason"] =
"limit";
559 result[
"next_offset"] = page_.offset +
returned;
561 result[
"reason"] =
"walk";
569 json items_ = json::array();
582std::string
display_path(
const fs::path& root,
const fs::path& path) {
584 ? path.lexically_relative(root).generic_string()
618 void take(
long n,
const std::string&
line) {
619 const long size =
static_cast<long>(
line.size()) + 1;
620 const bool full = (
w.limit > 0 &&
static_cast<long>(
lines.size()) >=
w.limit)
621 || (
w.budget > 0 &&
bytes + size >
w.budget);
622 if (
w.offset >
n ||
more) {
return; }
625 +
" is " + std::to_string(size) +
" bytes, more than the "
626 "whole read budget (" + std::to_string(
w.budget) +
")");
632 entry[
"content"] =
line;
633 lines.push_back(std::move(entry));
658 const ReadWindow&
w, std::string&
err) {
659 std::ifstream
in(path, std::ios::binary);
670 result[
"offset"] =
w.offset;
671 result[
"truncated"] =
reader.more;
673 result[
"next_offset"] =
w.offset +
static_cast<long>(
reader.lines.size());
675 result[
"lines"] = std::move(
reader.lines);
696 bool dbl = (
i + 1 <
pat.size()) && (
pat[
i + 1] ==
'*');
697 if (!
dbl) {
return; }
699 if (
i + 1 <
pat.size() &&
pat[
i + 1] ==
'/') { ++
i; }
725 }
else if (
ch ==
'?') {
727 }
else if (
ch ==
'.') {
737 }
catch (
const std::regex_error&
e) {
739 "glob_match: malformed pattern '{}' → {} — treated as "
774 const std::string& body) {
775 std::vector<std::string> out;
777 for (
char c : body) {
779 out.push_back(std::move(
current));
785 out.push_back(std::move(
current));
795 const std::vector<std::string>&
bases,
797 std::vector<std::string>
next;
799 for (
const auto& base :
bases) {
819 std::vector<std::string> out{
""};
823 auto close = (
c ==
'{')
826 bool is_group = (
c ==
'{') && (close != std::string::npos);
828 for (
auto&
s : out) {
s +=
c; }
832 auto body =
pattern.substr(
i + 1, close -
i - 1);
857 const FileAccessTracker& tracker,
858 const std::string& path) {
860 if (fs::exists(path) && !tracker.was_read(
session, path)) {
861 logger->warn(
"Read-before-write violation: session='{}' {}",
864 "File must be read before writing: " + path);
878 const std::string&
needle) {
881 while ((pos = content.find(
needle, pos)) != std::string::npos) {
899std::optional<std::string>
912 std::string result = content;
913 auto pos = result.find(
old_str);
914 while (pos != std::string::npos) {
930 long n =
static_cast<long>(std::count(content.begin(), content.end(),
'\n'));
931 return (!content.empty() && content.back() !=
'\n') ?
n + 1 :
n;
954 auto nl = content.find(
'\n', pos);
955 pos = (
nl == std::string::npos) ? content.size() :
nl + 1;
959 && content.back() !=
'\n';
962 }
else if (text.empty() || text.back() !=
'\n') {
965 return content.substr(0, pos) + text + content.substr(pos);
978 const std::vector<std::string>& patterns) {
979 for (
const auto&
p : patterns) {
1010enum class EntryAction {
1033 const fs::directory_entry& entry,
1034 const fs::path& root,
1035 const std::vector<std::string>& patterns,
1036 const IgnoreMatcher* ignore) {
1037 bool is_dir = entry.is_directory();
1038 EntryAction result = EntryAction::kSkip;
1041 auto rel = fs::relative(entry.path(), root).generic_string();
1043 && ignore !=
nullptr
1047 result = EntryAction::kSkipPrune;
1049 result = EntryAction::kSkip;
1050 }
else if (entry.is_regular_file()
1052 result = EntryAction::kTake;
1075 const fs::path& root,
1077 const IgnoreMatcher* ignore,
1079 PagedResult&
page) {
1082 auto it = fs::recursive_directory_iterator(
1083 root, fs::directory_options::skip_permission_denied);
1085 for (
auto& entry :
it) {
1087 if (
action == EntryAction::kSkipPrune) {
1088 it.disable_recursion_pending();
1089 }
else if (
action == EntryAction::kTake
1109 std::ifstream
in(path, std::ios::binary);
1112 return std::memchr(
buf,
'\0',
static_cast<size_t>(
in.gcount())) !=
nullptr;
1126 const std::regex&
re, PagedResult&
page) {
1127 std::ifstream
in(path);
1133 if (std::regex_search(
line,
re)) {
1137 m[
"content"] =
line;
1141 return !
page.full();
1152json
entry_to_json(
const fs::directory_entry& entry,
const fs::path& base) {
1154 j[
"name"] = entry.path().lexically_relative(base).generic_string();
1155 bool dir = entry.is_directory();
1156 j[
"type"] =
dir ?
"directory" :
"file";
1157 j[
"size"] = (!
dir && entry.is_regular_file())
1158 ?
static_cast<int64_t>(entry.file_size()) : 0;
1182 std::vector<json> entries;
1183 auto it = fs::recursive_directory_iterator(
1184 dir, fs::directory_options::skip_permission_denied);
1185 for (
auto& entry :
it) {
1187 it.disable_recursion_pending();
1192 std::sort(entries.begin(), entries.end(),
1193 [](
const json&
a,
const json&
b) { return a[
"name"] < b[
"name"]; });
1211 const std::string& content,
1212 std::string& out, json&
ok) {
1213 auto old_str = args.at(
"old_string").get<std::string>();
1214 auto new_str = args.at(
"new_string").get<std::string>();
1215 bool replace_all = args.value(
"replace_all",
false);
1220 if (!result.has_value()) {
1222 ?
"old_string found multiple times — add context or use replace_all"
1223 :
"old_string not found in file";
1226 out = result.value();
1227 ok[
"mode"] =
"str_replace";
1229 ok[
"message"] =
"Edit applied";
1245 const std::string& content,
1246 std::string& out, json&
ok) {
1247 const long after = args.at(
"insert_line").get<
long>();
1250 return make_error(
"out_of_range",
"insert_line must be 0.."
1251 + std::to_string(
total) +
" for this file (got "
1252 + std::to_string(
after) +
")");
1255 ok[
"mode"] =
"insert";
1257 ok[
"message"] =
"Text inserted";
1275 const std::filesystem::path& resolved,
1277 const bool replace = args.contains(
"old_string");
1278 if (
replace == args.contains(
"insert_line")) {
1279 return make_error(
"invalid_args",
"edit_file takes exactly one of "
1280 "old_string (STR_REPLACE) or insert_line (INSERT)");
1317 "read_file",
"filesystem",
1354 const std::string&
args_json)
const override {
1356 return "file:" + args.at(
"path").get<std::string>();
1391 const fs::path& resolved,
1394 auto rel = fs::relative(resolved, server.
root_dir()).generic_string();
1395 if (!fs::exists(resolved)) {
1398 +
". Use list_directory(\".\") to see available files"
1399 " in the working directory, or verify the path is"
1400 " relative to the configured root.");
1401 }
else if (fs::is_directory(resolved)) {
1403 "Path is a directory, not a file: " +
display);
1406 "Path '" +
rel +
"' is excluded by .gitignore or "
1407 ".explorerignore.");
1422 w.offset = args.value<
long>(
"offset", 1);
1423 w.limit = args.value<
long>(
"limit", 0);
1425 return (1 >
w.offset || 0 >
w.limit)
1426 ?
make_error(
"invalid_args",
"offset must be >= 1 and limit >= 1")
1443 const std::string&
display,
const json& args,
1452 logger->info(
"Read file: {} lines {}..{} of {} (truncated={})",
1453 resolved.string(),
w.offset,
1454 w.offset +
static_cast<long>(result[
"lines"].size()) - 1,
1455 result[
"total"].get<
long>(), result[
"truncated"].get<
bool>());
1479 auto resolved = server_.
resolve_path(args.at(
"path").get<std::string>(),
1485 return {
err.empty() ? result.dump() :
err, {}};
1507 "write_file",
"filesystem",
1543 auto requested = args.at(
"path").get<std::string>();
1544 auto content = args.at(
"content").get<std::string>();
1556 logger->info(
"Wrote file: {} ({} bytes)",
1561 result[
"bytes_written"] = content.size();
1562 result[
"message"] =
"File written successfully";
1563 return {result.dump(), {}};
1585 "edit_file",
"filesystem",
1615 auto requested = args.at(
"path").get<std::string>();
1619 server_.
tracker(), resolved.string());
1645 "glob",
"filesystem",
1687 auto pattern = args.at(
"pattern").get<std::string>();
1690 if (!
err.empty()) {
return {
err, {}}; }
1697 page.finish(result,
"paths",
budget.truncated);
1698 logger->info(
"Glob '{}': {} paths, {} entries walked, truncated={}{}",
1700 result[
"truncated"].get<
bool>(),
1701 budget.truncated ?
" (walk cap — narrow the pattern)" :
"");
1702 return {result.dump(), {}};
1722 "grep",
"filesystem",
1766 }
catch (
const std::regex_error&
e) {
1768 return std::regex(
"(?!)");
1796 const fs::path& root,
const fs::path& start,
1800 auto it = fs::recursive_directory_iterator(
1801 start, fs::directory_options::skip_permission_denied);
1802 for (
auto& entry :
it) {
1805 if (
action == EntryAction::kSkipPrune) {
1806 it.disable_recursion_pending();
1807 }
else if (
action == EntryAction::kTake
1828 const json& args,
const std::regex&
re,
1830 const auto& root = server.
root_dir();
1831 if (fs::is_regular_file(start)) {
1857 if (
err.empty() && !fs::exists(start)) {
1883 auto start = server_.
resolve_path(args.value(
"path", std::string(
".")),
1888 if (!
err.empty()) {
return {
err, {}}; }
1894 page.finish(result,
"matches",
budget.truncated);
1895 logger->info(
"Grep '{}' in {}: {} matches, {} entries walked, truncated={}",
1896 args.at(
"pattern").get<std::string>(), start.string(),
1897 result[
"matches"].size(),
budget.visited,
1898 result[
"truncated"].get<
bool>());
1899 return {result.dump(), {}};
1921 "list_directory",
"filesystem",
1964 args.value(
"max_depth", 3),
budget)) {
1965 if (!
page.offer(std::move(
e))) {
break; }
1967 page.finish(result,
"entries",
budget.truncated);
1991 auto resolved = server_.
resolve_path(args.value(
"path", std::string(
".")),
1996 if (
err.empty() && !fs::is_directory(resolved)) {
1999 if (!
err.empty()) {
return {
err, {}}; }
2004 logger->info(
"Listed {}: {} entries, truncated={}",
display,
2005 result[
"entries"].size(), result[
"truncated"].
get<bool>());
2006 return {result.dump(), {}};
2024 if (
config.max_read_bytes.has_value()) {
2025 return config.max_read_bytes.value();
2033 return static_cast<int>(
2054 const fs::path& root_dir,
2065 register_fs_tools();
2070 ignore_.
load(root_dir_);
2072 logger->info(
"FilesystemServer initialized: root={}, "
2073 "max_read_bytes={}, ignore_rules={}",
2077 log_outside_root_policy();
2091void FilesystemServer::create_fs_tools(
const std::string&
data_dir) {
2092 read_file_ = std::make_unique<ReadFileTool>(*
this,
data_dir);
2093 write_file_ = std::make_unique<WriteFileTool>(*
this,
data_dir);
2094 edit_file_ = std::make_unique<EditFileTool>(*
this,
data_dir);
2095 glob_ = std::make_unique<GlobTool>(*
this,
data_dir);
2096 grep_ = std::make_unique<GrepTool>(*
this,
data_dir);
2097 list_dir_ = std::make_unique<ListDirectoryTool>(*
this,
data_dir);
2109void FilesystemServer::register_fs_tools() {
2139 const std::string& tool_name)
const {
2140 return tool_name ==
"read_file";
2158 auto canonical = fs::weakly_canonical(path);
2160 logger->error(
"set_working_dir: not a directory: {}",
2166 ignore_.
load(root_dir_);
2167 logger->info(
"Working directory changed to: {} (ignore_rules={})",
2170 log_outside_root_policy();
2249 return max_read_bytes_;
2281 const std::string& tool)
const {
2284 fs::path resolved =
req_path.is_absolute()
2286 : fs::weakly_canonical(root_dir_ /
req_path);
2289 authorize_outside_root(resolved, access, tool);
2312void FilesystemServer::authorize_outside_root(
2314 const std::string& tool)
const {
2318 if (
denied.has_value()) {
2325 ask_outside_root_approver(resolved, access, tool);
2346void FilesystemServer::ask_outside_root_approver(
2348 const std::string& tool)
const {
2352 std::lock_guard<std::mutex> lock(approver_mutex_);
2354 ud = approver_data_;
2356 OutsideRootRequest
req{resolved.string(), root_dir_.string(),
2357 name() +
"." + tool, access};
2358 auto verdict =
fn !=
nullptr ?
fn(
req,
ud)
2361 logger->info(
"Outside-root {} APPROVED by host: {} ({})",
2365 logger->error(
"Path escape blocked: {}",
msg);
2366 throw std::runtime_error(
msg);
2379 std::lock_guard<std::mutex> lock(approver_mutex_);
2381 approver_data_ = user_data;
2382 logger->info(
"Outside-root approver {} (root: {})",
2383 fn !=
nullptr ?
"installed" :
"cleared",
2384 root_dir_.string());
2399void FilesystemServer::log_outside_root_policy()
const {
2402 logger->warn(
"Outside-root policy: allow_outside_root=true — every "
2403 "path outside {} is served (deny={})",
2406 logger->info(
"Outside-root policy: allow_outside_root={} allow={} "
2407 "deny={} (root: {})",
Tracks file read state for read-before-write enforcement.
bool was_read(const std::string &session, const std::string &path) const
Check whether session has read a file.
void record_read(const std::string &session, const std::string &path, size_t hash)
Record that session read a file.
std::size_t session_count() const
Sessions currently holding recorded reads.
bool release_session(const std::string &session)
Forget every read session recorded (gh#158).
Filesystem MCP server with read-before-write enforcement.
int max_read_bytes() const
Get max read bytes (size gate).
void set_outside_root_approver(OutsideRootApprover fn, void *user_data)
Install the approver consulted under optional (v2.13.0).
const IgnoreMatcher & ignore() const
Get the ignore matcher (#15, v2.1.4).
std::size_t session_count() const override
Sessions whose reads this server currently tracks (gh#158).
bool skip_duplicate_check(const std::string &tool_name) const override
read_file must always execute (updates FileAccessTracker).
bool set_working_dir(const std::string &path) override
Set working directory (changes root_dir).
~FilesystemServer() override
Destructor (default, unique_ptr cleanup).
const FilesystemConfig & config() const
Get the filesystem config.
std::filesystem::path resolve_path(const std::string &requested, PathAccess access, const std::string &tool) const
Resolve a path and apply the outside-root policy (v2.13.0).
FileAccessTracker & tracker()
Get the file access tracker.
FilesystemServer(const std::filesystem::path &root_dir, const FilesystemConfig &config, const std::string &data_dir, int model_context_bytes=0)
Construct with root directory, config, and data dir.
bool release_session(const std::string &key) override
Release key's recorded reads (gh#158).
const std::filesystem::path & root_dir() const
Get the root directory.
gitignore-style path matcher (#15, v2.1.4).
static bool is_skipped_dir_name(const std::string &name)
Directory names never walked, by name alone (gh#161).
void load(const std::filesystem::path &root)
Load gitignore + explorerignore from a workspace root.
bool is_ignored(const std::string &rel_path, bool is_dir) const
Test whether a path is ignored.
std::size_t rule_count() const
Number of compiled rules (test surface).
Concrete base class for MCP servers (80% logic).
const std::string & name() const
Get the server name.
void register_tool(ToolBase *tool)
Register a tool with this server.
auto peek(const std::string &key, Fn &&fn) const
Run fn on key's state WITHOUT creating it.
bool release(const std::string &key)
Forget key's state.
auto with(const std::string &key, Fn &&fn)
Run fn on key's state, creating it empty on first use.
std::size_t session_count() const
Sessions currently holding state (bounded-growth probe).
json lines
Lines taken so far.
long visited
Entries visited so far.
bool more
A line past the window exists.
std::string err
size_exceeded, when set
const ReadWindow & w
Requested window.
long budget
Max content bytes; <= 0 = unbounded.
bool truncated
Walk stopped on the cap.
int max_entries
<= 0 means unbounded
long bytes
Content bytes taken.
long limit
Results to keep.
long offset
Results to skip (from a previous next_offset)
Filesystem MCP server — read/write/edit/glob/grep/list_directory.
spdlog initialization and logger access.
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Activate model on GPU (WARM → ACTIVE).
ToolDefinition load_tool_definition(const std::string &tool_name, const std::string &server_prefix, const std::string &data_dir)
Load a tool definition from a JSON file.
static int compute_max_read_bytes(const FilesystemConfig &config, int model_context_bytes)
Compute max read bytes from config and model context.
@ ok
Tool dispatched, returned non-empty content.
static void grep_search(const fs::path &root, const fs::path &start, const std::vector< std::string > &file_patterns, const std::regex &re, const IgnoreMatcher &ignore, WalkBudget &budget, PagedResult &page)
Execute grep: brace-expand the file glob, compile the content regex (error-safe), iterate the tree ap...
OutsideRootVerdict
The host's answer to an OutsideRootRequest (v2.13.0).
@ approved
Serve this one call.
@ rejected
The approver said no.
@ no_approver
No approver is registered — refuse, fail loud.
OutsideRootAccess
What the filesystem server does with a path outside its root.
@ refuse
false: every escape refused ("Path escapes project root")
@ allow
true: every escape served, no prompt
@ optional
optional: every escape asks the host's path approver
static void grep_target(FilesystemServer &server, const fs::path &start, const json &args, const std::regex &re, WalkBudget &budget, PagedResult &page)
Search start — one file, or a tree under the glob filter.
static void list_page(const fs::path &dir, const json &args, Page requested, int max_walk, json &result)
Collect one page of a directory listing into result.
@ count
Sentinel — MUST remain last.
ENTROPIC_EXPORT std::string current_run_session()
The session key of the run executing on this thread (gh#166).
OutsideRootVerdict(*)(const OutsideRootRequest &request, void *user_data) OutsideRootApprover
Approver the server consults under allow_outside_root: optional.
PathAccess
What a filesystem tool is about to do with a path (v2.13.0).
@ read
read_file, list_directory
@ write
write_file, edit_file
std::regex compile_grep_or_error(const std::string &pattern, std::string &err)
Compile a regex or return a structured tool error.
static std::string grep_preflight(const json &args, const fs::path &start, const fs::path &root, Page &requested, std::regex &re)
Validate a grep call: paging, regex, and that path exists.
MCPAccessLevel
MCP tool access level for per-identity authorization.
@ READ
Read-only operations (e.g., read_file, list_directory)
std::string check_read_gates(FilesystemServer &server, const fs::path &resolved, const std::string &display)
Execute read_file: resolve, size-check, read, hash, track.
json read_and_track(FilesystemServer &server, const fs::path &resolved, const std::string &display, const json &args, std::string &err)
Read the requested window and record the read for the session.
std::string read_window(const json &args, long budget, ReadWindow &w)
The window a read_file call asks for, validated.
@ full
-1 or >= 99: the whole file is resident.
gh#158: the cancel token of the run executing on THIS thread.
std::string config
–config FILE
MCPServerBase concrete base class + ServerResponse.
Filesystem MCP server configuration.
std::vector< std::filesystem::path > outside_root_allow
Pre-approved subtrees outside the root — no prompt (v2.13.0).
std::vector< std::filesystem::path > outside_root_deny
Subtrees outside the root that are ALWAYS refused (v2.13.0).
int max_walk_entries
Hard cap on directory entries visited by ONE glob or grep walk; <= 0 disables the cap (gh#161).
OutsideRootAccess allow_outside_root
Paths OUTSIDE the root: refuse, allow, or ask (v2.13.0).
Structured result from tool execution.