18#include <nlohmann/json.hpp>
27namespace fs = std::filesystem;
28using json = nlohmann::json;
51 logger->info(
"Tracked read: {}", path);
64 return reads_.count(path) > 0;
76const std::vector<std::string> SKIP_DIRS = {
77 ".git",
"node_modules",
"__pycache__",
".venv"
87bool should_skip_dir(
const std::string& name) {
88 for (
const auto& skip : SKIP_DIRS) {
104std::string read_file_contents(
const fs::path& path) {
105 std::ifstream in(path, std::ios::binary);
107 throw std::runtime_error(
108 "Cannot open file: " + path.string());
110 std::ostringstream ss;
122void write_file_contents(
const fs::path& path,
123 const std::string& content) {
124 fs::create_directories(path.parent_path());
125 std::ofstream out(path, std::ios::binary | std::ios::trunc);
126 if (!out.is_open()) {
127 throw std::runtime_error(
128 "Cannot write file: " + path.string());
140size_t hash_content(
const std::string& s) {
141 return std::hash<std::string>{}(s);
159std::string make_error(
const std::string& code,
160 const std::string& message) {
163 j[
"message"] = message;
177std::string build_read_result(
const std::string& path,
178 const std::string& content) {
180 result[
"path"] = path;
182 std::istringstream stream(content);
184 json lines = json::array();
186 while (std::getline(stream, line)) {
187 lines.push_back(line);
190 result[
"total"] =
static_cast<int>(lines.size());
191 result[
"lines"] = std::move(lines);
192 return result.dump();
210void emit_glob_star(
const std::string& pat,
size_t& i, std::string& out) {
212 bool dbl = (i + 1 < pat.size()) && (pat[i + 1] ==
'*');
213 if (!dbl) {
return; }
215 if (i + 1 < pat.size() && pat[i + 1] ==
'/') { ++i; }
232bool glob_match(
const std::string& filename,
233 const std::string& pattern) {
234 std::string regex_str;
235 regex_str.reserve(pattern.size() * 2);
237 for (
size_t i = 0; i < pattern.size(); ++i) {
238 char ch = pattern[i];
240 emit_glob_star(pattern, i, regex_str);
241 }
else if (ch ==
'?') {
243 }
else if (ch ==
'.') {
251 std::regex re(regex_str, std::regex::icase);
252 return std::regex_match(filename, re);
253 }
catch (
const std::regex_error& e) {
255 "glob_match: malformed pattern '{}' → {} — treated as "
256 "non-match", pattern, e.what());
289std::vector<std::string> split_brace_alternatives(
290 const std::string& body) {
291 std::vector<std::string> out;
293 for (
char c : body) {
295 out.push_back(std::move(current));
301 out.push_back(std::move(current));
310std::vector<std::string> multiply_alternatives(
311 const std::vector<std::string>& bases,
312 const std::vector<std::string>& alternatives) {
313 std::vector<std::string> next;
314 next.reserve(bases.size() * alternatives.size());
315 for (
const auto& base : bases) {
316 for (
const auto& alt : alternatives) {
317 next.push_back(base + alt);
334std::vector<std::string> expand_braces(
const std::string& pattern) {
335 std::vector<std::string> out{
""};
337 while (i < pattern.size()) {
339 auto close = (c ==
'{')
340 ? pattern.find(
'}', i + 1)
342 bool is_group = (c ==
'{') && (close != std::string::npos);
344 for (
auto& s : out) { s += c; }
348 auto body = pattern.substr(i + 1, close - i - 1);
349 out = multiply_alternatives(
350 out, split_brace_alternatives(body));
370std::string check_read_before_write(
371 const FileAccessTracker& tracker,
372 const std::string& path) {
373 if (fs::exists(path) && !tracker.was_read(path)) {
374 logger->warn(
"Read-before-write violation: {}", path);
375 return make_error(
"read_before_write",
376 "File must be read before writing: " + path);
389int count_occurrences(
const std::string& content,
390 const std::string& needle) {
393 while ((pos = content.find(needle, pos)) != std::string::npos) {
395 pos += needle.size();
411std::optional<std::string>
412apply_str_replace(
const std::string& content,
const std::string& old_str,
const std::string& new_str,
bool replace_all, std::string& error_type) {
414 int occurrences = count_occurrences(content, old_str);
415 if (occurrences == 0) {
416 error_type =
"not_found";
419 if (!replace_all && occurrences > 1) {
420 error_type =
"multiple_matches";
424 std::string result = content;
425 auto pos = result.find(old_str);
426 while (pos != std::string::npos) {
427 result.replace(pos, old_str.size(), new_str);
428 if (!replace_all) {
break; }
429 pos = result.find(old_str, pos + new_str.size());
443std::string apply_insert(
const std::string& content,
445 const std::string& new_str) {
448 std::istringstream stream(content);
449 std::ostringstream out;
453 while (std::getline(stream, line)) {
455 if (current == line_num) {
456 out << new_str <<
'\n';
461 if (line_num > current) {
462 out << new_str <<
'\n';
476bool any_glob_match(
const std::string& filename,
477 const std::vector<std::string>& patterns) {
478 for (
const auto& p : patterns) {
479 if (glob_match(filename, p)) {
return true; }
509enum class EntryAction {
531EntryAction classify_glob_entry(
532 const fs::directory_entry& entry,
533 const fs::path& root,
534 const std::vector<std::string>& patterns,
535 const IgnoreMatcher* ignore) {
536 bool is_dir = entry.is_directory();
537 EntryAction result = EntryAction::kSkip;
538 bool hardcoded_skip = is_dir
539 && should_skip_dir(entry.path().filename().string());
540 auto rel = fs::relative(entry.path(), root).generic_string();
541 bool ignore_hit = !hardcoded_skip
544 && ignore->is_ignored(rel, is_dir);
545 if (hardcoded_skip || (ignore_hit && is_dir)) {
546 result = EntryAction::kSkipPrune;
547 }
else if (ignore_hit) {
548 result = EntryAction::kSkip;
549 }
else if (entry.is_regular_file()
550 && any_glob_match(rel, patterns)) {
551 result = EntryAction::kTake;
577std::vector<std::string> collect_glob_matches(
578 const fs::path& root,
579 const std::string& pattern,
581 const IgnoreMatcher* ignore =
nullptr) {
583 auto patterns = expand_braces(pattern);
584 std::vector<std::string> matches;
585 auto it = fs::recursive_directory_iterator(
586 root, fs::directory_options::skip_permission_denied);
588 for (
auto& entry : it) {
589 if (
static_cast<int>(matches.size()) >= max_results) {
592 auto action = classify_glob_entry(entry, root, patterns,
594 if (action == EntryAction::kSkipPrune) {
595 it.disable_recursion_pending();
596 }
else if (action == EntryAction::kTake) {
597 matches.push_back(entry.path().string());
616void grep_file(
const fs::path& path,
617 const std::regex& re,
618 std::vector<json>& matches,
620 std::ifstream in(path);
627 while (std::getline(in, line)) {
629 if (
static_cast<int>(matches.size()) >= limit) {
632 if (!std::regex_search(line, re)) {
636 m[
"path"] = path.string();
637 m[
"line"] = line_num;
639 matches.push_back(std::move(m));
650json entry_to_json(
const fs::directory_entry& entry) {
652 j[
"name"] = entry.path().filename().string();
654 if (entry.is_directory()) {
655 j[
"type"] =
"directory";
659 j[
"size"] = entry.is_regular_file()
660 ?
static_cast<int64_t
>(entry.file_size())
678std::vector<json> collect_entries(
const fs::path& dir,
681 std::vector<json> entries;
684 for (
auto& entry : fs::directory_iterator(dir)) {
685 entries.push_back(entry_to_json(entry));
690 auto it = fs::recursive_directory_iterator(
691 dir, fs::directory_options::skip_permission_denied);
692 for (
auto& entry : it) {
693 if (it.depth() > max_depth) {
694 it.disable_recursion_pending();
697 entries.push_back(entry_to_json(entry));
714std::string do_str_replace(
const json& args,
715 const std::string& content,
717 auto old_str = args.at(
"old_string").get<std::string>();
718 auto new_str = args.at(
"new_string").get<std::string>();
719 bool replace_all = args.value(
"replace_all",
false);
721 std::string error_type;
722 auto result = apply_str_replace(
723 content, old_str, new_str, replace_all, error_type);
724 if (!result.has_value()) {
725 auto msg = (error_type ==
"multiple_matches")
726 ?
"old_string found multiple times — use replace_all"
727 :
"old_string not found in file";
728 return make_error(error_type, msg);
730 out = result.value();
743std::string do_insert(
const json& args,
744 const std::string& content,
746 auto line_num = args.at(
"insert_line").get<
int>();
747 auto new_str = args.at(
"new_string").get<std::string>();
748 out = apply_insert(content, line_num, new_str);
765std::string apply_edit(
const json& args,
766 const std::filesystem::path& resolved,
767 const std::string& path_str) {
768 auto content = read_file_contents(resolved);
772 if (args.contains(
"old_string")) {
773 err = do_str_replace(args, content, edited);
774 }
else if (args.contains(
"insert_line")) {
775 err = do_insert(args, content, edited);
777 return make_error(
"invalid_args",
778 "edit_file requires old_string or insert_line");
785 write_file_contents(resolved, edited);
786 logger->info(
"Edited file: {}", path_str);
789 j[
"path"] = path_str;
790 j[
"message"] =
"Edit applied successfully";
813 const std::string& data_dir)
815 "read_file",
"filesystem",
816 data_dir +
"/tools")),
852 const std::string& args_json)
const override {
853 auto args = json::parse(args_json);
854 return "file:" + args.at(
"path").get<std::string>();
889 const fs::path& resolved,
890 const std::string& path_str) {
892 if (!fs::exists(resolved)) {
893 err = make_error(
"not_found",
894 "File not found: " + path_str
895 +
". Use list_directory(\".\") to see available files"
896 " in the working directory, or verify the path is"
897 " relative to the configured root.");
898 }
else if (fs::is_directory(resolved)) {
899 err = make_error(
"is_directory",
900 "Path is a directory, not a file: " + path_str);
902 auto rel = fs::relative(resolved, server.
root_dir())
904 bool ignored = !rel.empty()
906 int size =
static_cast<int>(fs::file_size(resolved));
909 err = make_error(
"ignored",
910 "Path '" + rel +
"' is excluded by .gitignore or "
912 }
else if (limit > 0 && size > limit) {
913 err = make_error(
"size_exceeded",
914 "File " + path_str +
" is " +
915 std::to_string(size) +
" bytes (limit: " +
916 std::to_string(limit) +
")");
940 auto args = json::parse(args_json);
941 auto requested = args.at(
"path").get<std::string>();
943 auto path_str = resolved.string();
946 if (!err.empty()) {
return {err, {}}; }
948 auto content = read_file_contents(resolved);
950 hash_content(content));
951 auto size =
static_cast<int>(fs::file_size(resolved));
952 logger->info(
"Read file: {} ({} bytes)", path_str, size);
953 return {build_read_result(path_str, content), {}};
973 const std::string& data_dir)
975 "write_file",
"filesystem",
976 data_dir +
"/tools")),
1007 const std::string& args_json) {
1009 auto args = json::parse(args_json);
1010 auto requested = args.at(
"path").get<std::string>();
1011 auto content = args.at(
"content").get<std::string>();
1013 auto path_str = resolved.string();
1015 auto violation = check_read_before_write(
1017 if (!violation.empty()) {
1018 return {violation, {}};
1021 write_file_contents(resolved, content);
1022 logger->info(
"Wrote file: {} ({} bytes)",
1023 path_str, content.size());
1026 result[
"path"] = path_str;
1027 result[
"bytes_written"] = content.size();
1028 result[
"message"] =
"File written successfully";
1029 return {result.dump(), {}};
1049 const std::string& data_dir)
1051 "edit_file",
"filesystem",
1052 data_dir +
"/tools")),
1076 auto args = json::parse(args_json);
1077 auto requested = args.at(
"path").get<std::string>();
1079 auto path_str = resolved.string();
1081 auto violation = check_read_before_write(
1083 if (!violation.empty()) {
1084 return {violation, {}};
1087 auto result = apply_edit(args, resolved, path_str);
1111 "glob",
"filesystem",
1112 data_dir +
"/tools")),
1151 auto args = json::parse(args_json);
1152 auto pattern = args.at(
"pattern").get<std::string>();
1153 constexpr int MAX_GLOB_RESULTS = 500;
1159 auto matches = collect_glob_matches(
1160 server_.
root_dir(), pattern, MAX_GLOB_RESULTS,
1163 logger->info(
"Glob '{}': {} matches (after ignore filtering)",
1164 pattern, matches.size());
1165 json result = matches;
1166 return {result.dump(), {}};
1186 "grep",
"filesystem",
1187 data_dir +
"/tools")),
1229 return std::regex(pattern);
1230 }
catch (
const std::regex_error& e) {
1231 err = make_error(
"invalid_regex", e.what());
1232 return std::regex(
"(?!)");
1260 const fs::path& root,
const std::vector<std::string>& file_patterns,
1262 constexpr int MAX_GREP_RESULTS = 100;
1263 std::vector<json> matches;
1264 auto it = fs::recursive_directory_iterator(
1265 root, fs::directory_options::skip_permission_denied);
1266 for (
auto& entry : it) {
1267 if (
static_cast<int>(matches.size()) >= MAX_GREP_RESULTS) {
1270 auto action = classify_glob_entry(entry, root, file_patterns,
1272 if (action == EntryAction::kSkipPrune) {
1273 it.disable_recursion_pending();
1274 }
else if (action == EntryAction::kTake) {
1275 grep_file(entry.path(), re, matches, MAX_GREP_RESULTS);
1293 auto args = json::parse(args_json);
1294 auto pattern = args.at(
"pattern").get<std::string>();
1295 auto file_glob = args.value(
"glob", std::string(
"*"));
1299 if (!err.empty()) {
return {err, {}}; }
1301 auto file_patterns = expand_braces(file_glob);
1305 logger->info(
"Grep '{}': {} matches (after ignore filtering)",
1306 pattern, matches.size());
1307 json result = matches;
1308 return {result.dump(), {}};
1328 const std::string& data_dir)
1330 "list_directory",
"filesystem",
1331 data_dir +
"/tools")),
1373 const std::string& args_json) {
1375 auto args = json::parse(args_json);
1376 auto requested = args.value(
"path", std::string(
"."));
1377 auto recursive = args.value(
"recursive",
false);
1378 auto max_depth = args.value(
"max_depth", 3);
1381 if (!fs::is_directory(resolved)) {
1382 return {make_error(
"not_directory",
1383 "Not a directory: " + resolved.string()), {}};
1386 auto entries = collect_entries(
1387 resolved, recursive, max_depth);
1389 logger->info(
"Listed {}: {} entries",
1390 resolved.string(), entries.size());
1391 json result = entries;
1392 return {result.dump(), {}};
1409 int model_context_bytes) {
1413 if (model_context_bytes <= 0) {
1419 return static_cast<int>(
1440 const fs::path& root_dir,
1442 const std::string& data_dir,
1443 int model_context_bytes)
1445 root_dir_(fs::weakly_canonical(root_dir)),
1448 config, model_context_bytes)) {
1450 create_fs_tools(data_dir);
1451 register_fs_tools();
1456 ignore_.
load(root_dir_);
1458 logger->info(
"FilesystemServer initialized: root={}, "
1459 "max_read_bytes={}, ignore_rules={}",
1476void FilesystemServer::create_fs_tools(
const std::string& data_dir) {
1477 read_file_ = std::make_unique<ReadFileTool>(*
this, data_dir);
1478 write_file_ = std::make_unique<WriteFileTool>(*
this, data_dir);
1479 edit_file_ = std::make_unique<EditFileTool>(*
this, data_dir);
1480 glob_ = std::make_unique<GlobTool>(*
this, data_dir);
1481 grep_ = std::make_unique<GrepTool>(*
this, data_dir);
1482 list_dir_ = std::make_unique<ListDirectoryTool>(*
this, data_dir);
1494void FilesystemServer::register_fs_tools() {
1524 const std::string& tool_name)
const {
1525 return tool_name ==
"read_file";
1543 auto canonical = fs::weakly_canonical(path);
1544 if (!fs::is_directory(canonical)) {
1545 logger->error(
"set_working_dir: not a directory: {}",
1549 root_dir_ = canonical;
1551 ignore_.
load(root_dir_);
1552 logger->info(
"Working directory changed to: {} (ignore_rules={})",
1609 return max_read_bytes_;
1635 const std::string& requested)
const {
1637 fs::path req_path(requested);
1638 fs::path resolved = req_path.is_absolute()
1639 ? fs::weakly_canonical(req_path)
1640 : fs::weakly_canonical(root_dir_ / req_path);
1642 fs::path rel = resolved.lexically_relative(root_dir_);
1643 bool under_root = !rel.empty()
1644 && *rel.begin() != fs::path(
"..")
1645 && rel != fs::path(
"..");
1648 logger->error(
"Path escape blocked: {} (root: {})",
1649 resolved.string(), root_dir_.string());
1650 throw std::runtime_error(
1651 "Path escapes project root: " + resolved.string());
Tracks file read state for read-before-write enforcement.
bool was_read(const std::string &path) const
Check if a file was ever read.
void record_read(const std::string &path, size_t hash)
Record that a file was read.
Filesystem MCP server with read-before-write enforcement.
int max_read_bytes() const
Get max read bytes (size gate).
const IgnoreMatcher & ignore() const
Get the ignore matcher (#15, v2.1.4).
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).
std::filesystem::path resolve_path(const std::string &requested) const
Resolve and validate a path against root.
const FilesystemConfig & config() const
Get the filesystem config.
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.
const std::filesystem::path & root_dir() const
Get the root directory.
gitignore-style path matcher (#15, v2.1.4).
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).
void register_tool(ToolBase *tool)
Register a tool with this server.
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.
@ count
Sentinel — MUST remain last.
std::regex compile_grep_or_error(const std::string &pattern, std::string &err)
Compile a regex or return a structured tool error.
MCPAccessLevel
MCP tool access level for per-identity authorization.
@ READ
Read-only operations (e.g., read_file, list_directory)
static std::vector< json > grep_search(const fs::path &root, const std::vector< std::string > &file_patterns, const std::regex &re, const IgnoreMatcher &ignore)
Execute grep: brace-expand the file glob, compile the content regex (error-safe), iterate the tree ap...
std::string check_read_gates(FilesystemServer &server, const fs::path &resolved, const std::string &path_str)
Execute read_file: resolve, size-check, read, hash, track.
MCPServerBase concrete base class + ServerResponse.
Filesystem MCP server configuration.
bool allow_outside_root
Allow file ops outside workspace root.
std::optional< int > max_read_bytes
Max file read size (nullopt = derive from context)
float max_read_context_pct
Max context % for single file read.
Structured result from tool execution.
std::string result
Human-readable result.