Entropic 2.11.1
Local-first agentic inference engine
Loading...
Searching...
No Matches
filesystem.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
17
18#include <nlohmann/json.hpp>
19
20#include <filesystem>
21#include <fstream>
22#include <functional>
23#include <optional>
24#include <regex>
25#include <sstream>
26
27namespace fs = std::filesystem;
28using json = nlohmann::json;
29
30static auto logger = entropic::log::get("mcp.filesystem");
31
32namespace entropic {
33
34// ── FileAccessTracker ────────────────────────────────────
35
48void FileAccessTracker::record_read(const std::string& path,
49 size_t hash) {
50 reads_[path] = hash;
51 logger->info("Tracked read: {}", path);
52}
53
54
63bool FileAccessTracker::was_read(const std::string& path) const {
64 return reads_.count(path) > 0;
65}
66
67// ── File-local helpers ───────────────────────────────────
68
69namespace {
70
76const std::vector<std::string> SKIP_DIRS = {
77 ".git", "node_modules", "__pycache__", ".venv"
78};
79
87bool should_skip_dir(const std::string& name) {
88 for (const auto& skip : SKIP_DIRS) {
89 if (name == skip) {
90 return true;
91 }
92 }
93 return false;
94}
95
104std::string read_file_contents(const fs::path& path) {
105 std::ifstream in(path, std::ios::binary);
106 if (!in.is_open()) {
107 throw std::runtime_error(
108 "Cannot open file: " + path.string());
109 }
110 std::ostringstream ss;
111 ss << in.rdbuf();
112 return ss.str();
113}
114
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());
129 }
130 out << content;
131}
132
140size_t hash_content(const std::string& s) {
141 return std::hash<std::string>{}(s);
142}
143
159std::string make_error(const std::string& code,
160 const std::string& message) {
161 json j;
162 j["error"] = code;
163 j["message"] = message;
164 return j.dump();
165}
166
177std::string build_read_result(const std::string& path,
178 const std::string& content) {
179 json result;
180 result["path"] = path;
181
182 std::istringstream stream(content);
183 std::string line;
184 json lines = json::array();
185
186 while (std::getline(stream, line)) {
187 lines.push_back(line);
188 }
189
190 result["total"] = static_cast<int>(lines.size());
191 result["lines"] = std::move(lines);
192 return result.dump();
193}
194
210void emit_glob_star(const std::string& pat, size_t& i, std::string& out) {
211 out += ".*";
212 bool dbl = (i + 1 < pat.size()) && (pat[i + 1] == '*');
213 if (!dbl) { return; }
214 ++i;
215 if (i + 1 < pat.size() && pat[i + 1] == '/') { ++i; }
216}
217
232bool glob_match(const std::string& filename,
233 const std::string& pattern) {
234 std::string regex_str;
235 regex_str.reserve(pattern.size() * 2);
236
237 for (size_t i = 0; i < pattern.size(); ++i) {
238 char ch = pattern[i];
239 if (ch == '*') {
240 emit_glob_star(pattern, i, regex_str);
241 } else if (ch == '?') {
242 regex_str += '.';
243 } else if (ch == '.') {
244 regex_str += "\\.";
245 } else {
246 regex_str += ch;
247 }
248 }
249
250 try {
251 std::regex re(regex_str, std::regex::icase);
252 return std::regex_match(filename, re);
253 } catch (const std::regex_error& e) {
254 logger->warn(
255 "glob_match: malformed pattern '{}' → {} — treated as "
256 "non-match", pattern, e.what());
257 return false;
258 }
259}
260
289std::vector<std::string> split_brace_alternatives(
290 const std::string& body) {
291 std::vector<std::string> out;
292 std::string current;
293 for (char c : body) {
294 if (c == ',') {
295 out.push_back(std::move(current));
296 current.clear();
297 } else {
298 current += c;
299 }
300 }
301 out.push_back(std::move(current));
302 return out;
303}
304
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);
318 }
319 }
320 return next;
321}
322
334std::vector<std::string> expand_braces(const std::string& pattern) {
335 std::vector<std::string> out{""};
336 size_t i = 0;
337 while (i < pattern.size()) {
338 char c = pattern[i];
339 auto close = (c == '{')
340 ? pattern.find('}', i + 1)
341 : std::string::npos;
342 bool is_group = (c == '{') && (close != std::string::npos);
343 if (!is_group) {
344 for (auto& s : out) { s += c; }
345 ++i;
346 continue;
347 }
348 auto body = pattern.substr(i + 1, close - i - 1);
349 out = multiply_alternatives(
350 out, split_brace_alternatives(body));
351 i = close + 1;
352 }
353 return out;
354}
355
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);
377 }
378 return "";
379}
380
389int count_occurrences(const std::string& content,
390 const std::string& needle) {
391 int count = 0;
392 size_t pos = 0;
393 while ((pos = content.find(needle, pos)) != std::string::npos) {
394 count++;
395 pos += needle.size();
396 }
397 return count;
398}
399
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) {
413
414 int occurrences = count_occurrences(content, old_str);
415 if (occurrences == 0) {
416 error_type = "not_found";
417 return std::nullopt;
418 }
419 if (!replace_all && occurrences > 1) {
420 error_type = "multiple_matches";
421 return std::nullopt;
422 }
423
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());
430 }
431 return result;
432}
433
443std::string apply_insert(const std::string& content,
444 int line_num,
445 const std::string& new_str) {
446 // (#13/#15 v2.1.4: function body unchanged; doxygen-guard parser
447 // re-evaluates the position after surrounding helpers were added.)
448 std::istringstream stream(content);
449 std::ostringstream out;
450 std::string line;
451 int current = 0;
452
453 while (std::getline(stream, line)) {
454 ++current;
455 if (current == line_num) {
456 out << new_str << '\n';
457 }
458 out << line << '\n';
459 }
460
461 if (line_num > current) {
462 out << new_str << '\n';
463 }
464 return out.str();
465}
466
467
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; }
480 }
481 return false;
482}
483
509enum class EntryAction {
510 kSkip,
511 kSkipPrune,
512 kTake
513};
514
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
542 && ignore != nullptr
543 && !rel.empty()
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;
552 }
553 return result;
554}
555
577std::vector<std::string> collect_glob_matches(
578 const fs::path& root,
579 const std::string& pattern,
580 int max_results,
581 const IgnoreMatcher* ignore = nullptr) {
582
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);
587
588 for (auto& entry : it) {
589 if (static_cast<int>(matches.size()) >= max_results) {
590 break;
591 }
592 auto action = classify_glob_entry(entry, root, patterns,
593 ignore);
594 if (action == EntryAction::kSkipPrune) {
595 it.disable_recursion_pending();
596 } else if (action == EntryAction::kTake) {
597 matches.push_back(entry.path().string());
598 }
599 }
600 return matches;
601}
602
616void grep_file(const fs::path& path,
617 const std::regex& re,
618 std::vector<json>& matches,
619 int limit) {
620 std::ifstream in(path);
621 if (!in.is_open()) {
622 return;
623 }
624
625 std::string line;
626 int line_num = 0;
627 while (std::getline(in, line)) {
628 ++line_num;
629 if (static_cast<int>(matches.size()) >= limit) {
630 return;
631 }
632 if (!std::regex_search(line, re)) {
633 continue;
634 }
635 json m;
636 m["path"] = path.string();
637 m["line"] = line_num;
638 m["content"] = line;
639 matches.push_back(std::move(m));
640 }
641}
642
650json entry_to_json(const fs::directory_entry& entry) {
651 json j;
652 j["name"] = entry.path().filename().string();
653
654 if (entry.is_directory()) {
655 j["type"] = "directory";
656 j["size"] = 0;
657 } else {
658 j["type"] = "file";
659 j["size"] = entry.is_regular_file()
660 ? static_cast<int64_t>(entry.file_size())
661 : 0;
662 }
663 return j;
664}
665
678std::vector<json> collect_entries(const fs::path& dir,
679 bool recursive,
680 int max_depth) {
681 std::vector<json> entries;
682
683 if (!recursive) {
684 for (auto& entry : fs::directory_iterator(dir)) {
685 entries.push_back(entry_to_json(entry));
686 }
687 return entries;
688 }
689
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();
695 continue;
696 }
697 entries.push_back(entry_to_json(entry));
698 }
699 return entries;
700}
701
714std::string do_str_replace(const json& args,
715 const std::string& content,
716 std::string& out) {
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);
720
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);
729 }
730 out = result.value();
731 return "";
732}
733
743std::string do_insert(const json& args,
744 const std::string& content,
745 std::string& out) {
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);
749 return "";
750}
751
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);
769 std::string edited;
770 std::string err;
771
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);
776 } else {
777 return make_error("invalid_args",
778 "edit_file requires old_string or insert_line");
779 }
780
781 if (!err.empty()) {
782 return err;
783 }
784
785 write_file_contents(resolved, edited);
786 logger->info("Edited file: {}", path_str);
787
788 json j;
789 j["path"] = path_str;
790 j["message"] = "Edit applied successfully";
791 return j.dump();
792}
793
794} // anonymous namespace
795
796// ── ReadFileTool ─────────────────────────────────────────
797
803class ReadFileTool : public ToolBase {
804public:
813 const std::string& data_dir)
815 "read_file", "filesystem",
816 data_dir + "/tools")),
817 server_(server) {}
818
828 }
829
837 ServerResponse execute(const std::string& args_json) override;
838
851 std::string anchor_key(
852 const std::string& args_json) const override {
853 auto args = json::parse(args_json);
854 return "file:" + args.at("path").get<std::string>();
855 }
856
857private:
858 FilesystemServer& server_;
859};
860
889 const fs::path& resolved,
890 const std::string& path_str) {
891 std::string err;
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);
901 } else {
902 auto rel = fs::relative(resolved, server.root_dir())
903 .generic_string();
904 bool ignored = !rel.empty()
905 && server.ignore().is_ignored(rel, /*is_dir=*/false);
906 int size = static_cast<int>(fs::file_size(resolved));
907 int limit = server.max_read_bytes();
908 if (ignored) {
909 err = make_error("ignored",
910 "Path '" + rel + "' is excluded by .gitignore or "
911 ".explorerignore.");
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) + ")");
917 }
918 }
919 return err;
920}
921
939ServerResponse ReadFileTool::execute(const std::string& args_json) {
940 auto args = json::parse(args_json);
941 auto requested = args.at("path").get<std::string>();
942 auto resolved = server_.resolve_path(requested);
943 auto path_str = resolved.string();
944
945 auto err = check_read_gates(server_, resolved, path_str);
946 if (!err.empty()) { return {err, {}}; }
947
948 auto content = read_file_contents(resolved);
949 server_.tracker().record_read(path_str,
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), {}};
954}
955
956// ── WriteFileTool ────────────────────────────────────────
957
963class WriteFileTool : public ToolBase {
964public:
973 const std::string& data_dir)
975 "write_file", "filesystem",
976 data_dir + "/tools")),
977 server_(server) {}
978
986 ServerResponse execute(const std::string& args_json) override;
987
988private:
989 FilesystemServer& server_;
990};
991
1007 const std::string& args_json) {
1008
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>();
1012 auto resolved = server_.resolve_path(requested);
1013 auto path_str = resolved.string();
1014
1015 auto violation = check_read_before_write(
1016 server_.tracker(), path_str);
1017 if (!violation.empty()) {
1018 return {violation, {}};
1019 }
1020
1021 write_file_contents(resolved, content);
1022 logger->info("Wrote file: {} ({} bytes)",
1023 path_str, content.size());
1024
1025 json result;
1026 result["path"] = path_str;
1027 result["bytes_written"] = content.size();
1028 result["message"] = "File written successfully";
1029 return {result.dump(), {}};
1030}
1031
1032// ── EditFileTool ─────────────────────────────────────────
1033
1039class EditFileTool : public ToolBase {
1040public:
1049 const std::string& data_dir)
1051 "edit_file", "filesystem",
1052 data_dir + "/tools")),
1053 server_(server) {}
1054
1062 ServerResponse execute(const std::string& args_json) override;
1063
1064private:
1065 FilesystemServer& server_;
1066};
1067
1075ServerResponse EditFileTool::execute(const std::string& args_json) {
1076 auto args = json::parse(args_json);
1077 auto requested = args.at("path").get<std::string>();
1078 auto resolved = server_.resolve_path(requested);
1079 auto path_str = resolved.string();
1080
1081 auto violation = check_read_before_write(
1082 server_.tracker(), path_str);
1083 if (!violation.empty()) {
1084 return {violation, {}};
1085 }
1086
1087 auto result = apply_edit(args, resolved, path_str);
1088 ServerResponse resp;
1089 resp.result = result;
1090 return resp;
1091}
1092
1093// ── GlobTool ─────────────────────────────────────────────
1094
1100class GlobTool : public ToolBase {
1101public:
1109 GlobTool(FilesystemServer& server, const std::string& data_dir)
1111 "glob", "filesystem",
1112 data_dir + "/tools")),
1113 server_(server) {}
1114
1123 return MCPAccessLevel::READ;
1124 }
1125
1133 ServerResponse execute(const std::string& args_json) override;
1134
1135private:
1136 FilesystemServer& server_;
1137};
1138
1150ServerResponse GlobTool::execute(const std::string& args_json) {
1151 auto args = json::parse(args_json);
1152 auto pattern = args.at("pattern").get<std::string>();
1153 constexpr int MAX_GLOB_RESULTS = 500;
1154
1155 // Issue #15 (v2.1.4): pass server's IgnoreMatcher so build/, vendor/,
1156 // doxygen/, and anything else listed in .gitignore + .explorerignore
1157 // is filtered out. Pre-2.1.4 only the hardcoded SKIP_DIRS were honored.
1158 // Issue #13 (v2.1.4): brace expansion handled inside.
1159 auto matches = collect_glob_matches(
1160 server_.root_dir(), pattern, MAX_GLOB_RESULTS,
1161 &server_.ignore());
1162
1163 logger->info("Glob '{}': {} matches (after ignore filtering)",
1164 pattern, matches.size());
1165 json result = matches;
1166 return {result.dump(), {}};
1167}
1168
1169// ── GrepTool ─────────────────────────────────────────────
1170
1176class GrepTool : public ToolBase {
1177public:
1184 GrepTool(FilesystemServer& server, const std::string& data_dir)
1186 "grep", "filesystem",
1187 /* tools dir: */ data_dir + "/tools")),
1188 server_(server) {}
1189
1198 return MCPAccessLevel::READ;
1199 }
1200
1208 ServerResponse execute(const std::string& args_json) override;
1209
1210private:
1211 FilesystemServer& server_;
1212};
1213
1226std::regex compile_grep_or_error(const std::string& pattern,
1227 std::string& err) {
1228 try {
1229 return std::regex(pattern);
1230 } catch (const std::regex_error& e) {
1231 err = make_error("invalid_regex", e.what());
1232 return std::regex("(?!)");
1233 }
1234}
1235
1259static std::vector<json> grep_search(
1260 const fs::path& root, const std::vector<std::string>& file_patterns,
1261 const std::regex& re, const IgnoreMatcher& ignore) {
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) {
1268 break;
1269 }
1270 auto action = classify_glob_entry(entry, root, file_patterns,
1271 &ignore);
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);
1276 }
1277 }
1278 return matches;
1279}
1280
1292ServerResponse GrepTool::execute(const std::string& args_json) {
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("*"));
1296
1297 std::string err;
1298 auto re = compile_grep_or_error(pattern, err);
1299 if (!err.empty()) { return {err, {}}; }
1300
1301 auto file_patterns = expand_braces(file_glob);
1302 auto matches = grep_search(server_.root_dir(), file_patterns, re,
1303 server_.ignore());
1304
1305 logger->info("Grep '{}': {} matches (after ignore filtering)",
1306 pattern, matches.size());
1307 json result = matches;
1308 return {result.dump(), {}};
1309}
1310
1311// ── ListDirectoryTool ────────────────────────────────────
1312
1319public:
1328 const std::string& data_dir)
1330 "list_directory", "filesystem",
1331 data_dir + "/tools")),
1332 server_(server) {}
1333
1342 return MCPAccessLevel::READ;
1343 }
1344
1352 ServerResponse execute(const std::string& args_json) override;
1353
1354private:
1355 FilesystemServer& server_;
1356};
1357
1373 const std::string& args_json) {
1374
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);
1379
1380 auto resolved = server_.resolve_path(requested);
1381 if (!fs::is_directory(resolved)) {
1382 return {make_error("not_directory",
1383 "Not a directory: " + resolved.string()), {}};
1384 }
1385
1386 auto entries = collect_entries(
1387 resolved, recursive, max_depth);
1388
1389 logger->info("Listed {}: {} entries",
1390 resolved.string(), entries.size());
1391 json result = entries;
1392 return {result.dump(), {}};
1393}
1394
1395// ── FilesystemServer ─────────────────────────────────────
1396
1409 int model_context_bytes) {
1410 if (config.max_read_bytes.has_value()) {
1411 return config.max_read_bytes.value();
1412 }
1413 if (model_context_bytes <= 0) {
1414 // Safe default: 32KB prevents a single file from blowing typical
1415 // context budgets (16-128K). Large files trigger size_exceeded and
1416 // the model must use offset/limit or docs.* tools instead.
1417 return 32 * 1024;
1418 }
1419 return static_cast<int>(
1420 model_context_bytes * config.max_read_context_pct);
1421}
1422
1440 const fs::path& root_dir,
1441 const FilesystemConfig& config,
1442 const std::string& data_dir,
1443 int model_context_bytes)
1444 : MCPServerBase("filesystem"),
1445 root_dir_(fs::weakly_canonical(root_dir)),
1446 config_(config),
1447 max_read_bytes_(compute_max_read_bytes(
1448 config, model_context_bytes)) {
1449
1450 create_fs_tools(data_dir);
1451 register_fs_tools();
1452
1453 // Issue #15 (v2.1.4): load .gitignore + .explorerignore so glob,
1454 // grep, and read_file can filter out build artifacts and vendor
1455 // blobs that pre-2.1.4 leaked into results.
1456 ignore_.load(root_dir_);
1457
1458 logger->info("FilesystemServer initialized: root={}, "
1459 "max_read_bytes={}, ignore_rules={}",
1460 root_dir_.string(),
1461 max_read_bytes_,
1462 ignore_.rule_count());
1463}
1464
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);
1483}
1484
1494void FilesystemServer::register_fs_tools() {
1495 register_tool(read_file_.get());
1496 register_tool(write_file_.get());
1497 register_tool(edit_file_.get());
1498 register_tool(glob_.get());
1499 register_tool(grep_.get());
1500 register_tool(list_dir_.get());
1501}
1502
1509
1524 const std::string& tool_name) const {
1525 return tool_name == "read_file";
1526}
1527
1542bool FilesystemServer::set_working_dir(const std::string& path) {
1543 auto canonical = fs::weakly_canonical(path);
1544 if (!fs::is_directory(canonical)) {
1545 logger->error("set_working_dir: not a directory: {}",
1546 path);
1547 return false;
1548 }
1549 root_dir_ = canonical;
1550 // Issue #15 (v2.1.4): reload ignore rules for the new root.
1551 ignore_.load(root_dir_);
1552 logger->info("Working directory changed to: {} (ignore_rules={})",
1553 root_dir_.string(), ignore_.rule_count());
1554 return true;
1555}
1556
1564const fs::path& FilesystemServer::root_dir() const {
1565 return root_dir_;
1566}
1567
1576 return tracker_;
1577}
1578
1588 return ignore_;
1589}
1590
1598 return config_;
1599}
1600
1609 return max_read_bytes_;
1610}
1611
1635 const std::string& requested) const {
1636
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);
1641
1642 fs::path rel = resolved.lexically_relative(root_dir_);
1643 bool under_root = !rel.empty()
1644 && *rel.begin() != fs::path("..")
1645 && rel != fs::path("..");
1646
1647 if (!under_root && !config_.allow_outside_root) {
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());
1652 }
1653 return resolved;
1654}
1655
1656} // namespace entropic
Tool for in-place file editing (string replace or insert).
ServerResponse execute(const std::string &args_json) override
Edit a file via string replacement or line insertion.
EditFileTool(FilesystemServer &server, const std::string &data_dir)
Construct from server reference and data directory.
Tracks file read state for read-before-write enforcement.
Definition filesystem.h:30
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.
Definition filesystem.h:65
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.
Tool for recursive file pattern matching.
MCPAccessLevel required_access_level() const override
Read-only tool — requires READ access.
ServerResponse execute(const std::string &args_json) override
Find files matching a glob pattern.
GlobTool(FilesystemServer &server, const std::string &data_dir)
Construct with server reference and data directory.
Tool for regex content search across files.
ServerResponse execute(const std::string &args_json) override
Search files for regex pattern matches.
MCPAccessLevel required_access_level() const override
Read-only tool — requires READ access.
GrepTool(FilesystemServer &server, const std::string &data_dir)
Construct from data 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).
Tool for listing directory contents.
MCPAccessLevel required_access_level() const override
Read-only tool — requires READ access.
ListDirectoryTool(FilesystemServer &server, const std::string &data_dir)
Construct from server reference and data directory.
ServerResponse execute(const std::string &args_json) override
List directory entries with optional recursion.
Concrete base class for MCP servers (80% logic).
Definition server_base.h:66
void register_tool(ToolBase *tool)
Register a tool with this server.
Tool for reading file contents with line numbering.
ReadFileTool(FilesystemServer &server, const std::string &data_dir)
Construct from server reference and data directory.
ServerResponse execute(const std::string &args_json) override
Read a file and return numbered lines as JSON.
MCPAccessLevel required_access_level() const override
Read-only tool — requires READ access.
std::string anchor_key(const std::string &args_json) const override
Anchor key for context deduplication.
Abstract base class for individual MCP tools.
Definition tool_base.h:45
Tool for writing file contents with read-before-write.
ServerResponse execute(const std::string &args_json) override
Write content to a file after read-before-write check.
WriteFileTool(FilesystemServer &server, const std::string &data_dir)
Construct from server reference and data directory.
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.
Definition logging.cpp:211
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.
Definition tool_base.cpp:99
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.
Definition config.h:38
@ 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.
Definition config.h:629
bool allow_outside_root
Allow file ops outside workspace root.
Definition config.h:633
std::optional< int > max_read_bytes
Max file read size (nullopt = derive from context)
Definition config.h:634
float max_read_context_pct
Max context % for single file read.
Definition config.h:635
Structured result from tool execution.
Definition server_base.h:33
std::string result
Human-readable result.
Definition server_base.h:34
Abstract base class for individual MCP tools.