Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
server_manager.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
9
10#include "discovery_policy.h"
20
21#include <nlohmann/json.hpp>
22
23#include <algorithm>
24
25static auto logger = entropic::log::get("mcp.server_manager");
26
27namespace entropic {
28
37 const PermissionsConfig& permissions,
38 const std::filesystem::path& project_dir)
39 : permissions_(permissions.allow, permissions.deny),
40 project_dir_(project_dir) {}
41
65 const MCPConfig& mcp,
66 const std::vector<std::string>& tier_names,
67 const std::string& data_dir,
68 const std::vector<std::string>& require_context_tiers) {
69 if (mcp.enable_entropic) {
70 register_server(std::make_unique<EntropicServer>(
72 }
73 if (mcp.enable_filesystem) {
74 register_server(std::make_unique<FilesystemServer>(
75 project_dir_, mcp.filesystem, data_dir));
76 }
77 if (mcp.enable_bash) {
78 register_server(std::make_unique<BashServer>(
79 project_dir_, data_dir, mcp.bash.timeout_seconds));
80 }
81 if (mcp.enable_git) {
82 register_server(std::make_unique<GitServer>(
83 project_dir_, data_dir));
84 }
85 if (mcp.enable_diagnostics) {
86 register_server(std::make_unique<DiagnosticsServer>(
87 project_dir_, data_dir));
88 }
89 if (mcp.enable_web) {
90 register_server(std::make_unique<WebServer>(data_dir));
91 }
92}
93
116
117 for (const auto& path : mcp.plugins) {
118 std::unique_ptr<PluginServer> plugin;
119 auto rc = PluginServer::load(path, plugin);
120 if (rc != ENTROPIC_OK) {
121 // Keep going: attempting every path diagnoses a whole broken
122 // config in one run instead of one entry per restart.
124 continue;
125 }
126
127 auto name = plugin->name();
128 if (servers_.count(name) > 0 || find_external(name) ||
129 plugin_servers_.count(name) > 0) {
130 logger->error("Plugin '{}' from {} collides with an already "
131 "registered server of the same name — refusing to "
132 "shadow it", name, path.string());
135 : first_error;
136 continue;
137 }
138
139 plugin->set_working_dir(project_dir_.string());
140 plugin_servers_[name] = std::move(plugin);
141 logger->info("Registered plugin server: {}", name);
142 }
143
144 return first_error;
145}
146
154 std::unique_ptr<MCPServerBase> server) {
155 auto name = server->name();
156 if (servers_.count(name) > 0) {
157 logger->warn("Server '{}' already registered — replacing",
158 name);
159 }
160 servers_[name] = std::move(server);
161 logger->info("Registered server: {}", name);
162}
163
170 logger->info("Initializing {} in-process MCP servers",
171 servers_.size());
172 for (auto& [name, server] : servers_) {
173 logger->info("Server '{}' ready", name);
174 }
175
176 initialize_external_servers();
177}
178
192static void append_prefixed_tools(const std::string& tools_json,
193 const std::string& prefix,
194 nlohmann::json& all) {
195 auto tools = nlohmann::json::parse(tools_json);
196 for (auto& tool : tools) {
197 std::string orig_name = tool.at("name");
198 tool["name"] = prefix + "." + orig_name;
199 all.push_back(std::move(tool));
200 }
201}
202
215void ServerManager::append_plugin_tools(nlohmann::json& all) const {
216 for (const auto& [name, plugin] : plugin_servers_) {
217 try {
218 append_prefixed_tools(plugin->list_tools(), name, all);
219 } catch (const std::exception& e) {
220 logger->error("Plugin '{}' returned an unusable tool list: {} — "
221 "its tools are unavailable this session", name,
222 e.what());
223 }
224 }
225}
226
237void ServerManager::append_external_tools(nlohmann::json& all) const {
238 for (const auto& [name, client] : external_snapshot()) {
239 if (!client->is_connected()) {
240 continue;
241 }
242 auto tools = nlohmann::json::parse(client->list_tools());
243 for (auto& tool : tools) {
244 all.push_back(std::move(tool));
245 }
246 }
247}
248
265 const std::filesystem::path& dir) {
266 std::lock_guard<ToolRootLock> exclusive(*root_lock_);
267 size_t moved = 0;
268 for (const auto& [name, server] : servers_) {
269 if (server->set_working_dir(dir.string())) { ++moved; }
270 }
271 for (const auto& [name, plugin] : plugin_servers_) {
272 if (plugin->set_working_dir(dir.string()) == ENTROPIC_OK) {
273 ++moved;
274 }
275 }
276 logger->info("Tool working dir → {} ({} servers moved)",
277 dir.string(), moved);
278 return moved;
279}
280
294size_t ServerManager::enter_working_dir(const std::filesystem::path& dir) {
295 root_lock_->lock();
297 logger->info("Tool root lock HELD for sandbox {} — other sessions' "
298 "in-process dispatch on this server set waits until "
299 "restore", dir.string());
300 return moved;
301}
302
311size_t ServerManager::leave_working_dir(const std::filesystem::path& dir) {
312 if (!root_lock_->owned_by_this_thread()) {
313 logger->error("leave_working_dir({}) from a thread that holds no "
314 "sandbox root lock — refusing, nothing moved",
315 dir.string());
316 return 0;
317 }
319 root_lock_->unlock();
320 logger->info("Tool root lock released on restore to {}", dir.string());
321 return moved;
322}
323
331static bool tool_is_read_only(const nlohmann::json& tool) {
332 if (!tool.is_object() || !tool.contains("annotations")) {
333 return false;
334 }
335 const auto& ann = tool["annotations"];
336 return ann.is_object() && ann.value("readOnlyHint", false);
337}
338
349 const std::string& tools_json,
350 const std::vector<std::string>& allowed) {
351 std::vector<std::string> unsafe;
352 auto tools = nlohmann::json::parse(tools_json, nullptr, false);
353 if (!tools.is_array()) { return unsafe; }
354 for (const auto& tool : tools) {
355 auto full = tool.value("name", std::string{});
356 bool visible = allowed.empty()
357 || std::find(allowed.begin(), allowed.end(), full)
358 != allowed.end();
359 if (visible && !full.empty() && !tool_is_read_only(tool)) {
360 unsafe.push_back(full);
361 }
362 }
363 return unsafe;
364}
365
373std::vector<std::string>
375 const std::vector<std::string>& allowed) const {
376 std::vector<std::string> unsafe;
377 for (const auto& [name, client] : external_snapshot()) {
378 if (!client->is_connected()) { continue; }
380 client->list_tools(), allowed);
381 unsafe.insert(unsafe.end(), found.begin(), found.end());
382 }
383 return unsafe;
384}
385
399std::string ServerManager::list_tools() const {
400 auto all = nlohmann::json::array();
401
402 // In-process servers
403 for (const auto& [name, server] : servers_) {
404 append_prefixed_tools(server->list_tools(), name, all);
405 }
406
407 append_plugin_tools(all); // gh#133 (v2.10.1)
408 append_external_tools(all);
409
410 logger->info("Tool list: {} tools from {} server(s) + {} plugin(s) + "
411 "{} external", all.size(), servers_.size(),
412 plugin_servers_.size(), external_snapshot().size());
413 return all.dump();
414}
415
436 const std::string& tool_name,
437 const std::string& args_json) {
438
439 // Check permissions first
440 auto pattern = tool_name + ":" + args_to_pattern(args_json);
441 if (permissions_.is_denied(tool_name, pattern)) {
442 logger->warn("Permission denied: {}", tool_name);
443 nlohmann::json resp;
444 resp["result"] = "Error: Permission denied for " + tool_name;
445 resp["directives"] = nlohmann::json::array();
446 return resp.dump();
447 }
448
449 return route_tool_call(tool_name, args_json);
450}
451
461MCPServerBase* ServerManager::get_server(const std::string& name) const {
462 auto it = servers_.find(name);
463 return (it != servers_.end()) ? it->second.get() : nullptr;
464}
465
483 void* user_data) {
484 ToolRootShared root(*root_lock_, "set_outside_root_approver");
485 auto* fs_server = dynamic_cast<FilesystemServer*>(
486 get_server("filesystem"));
487 if (fs_server != nullptr) {
489 }
490 return fs_server != nullptr;
491}
492
508std::size_t ServerManager::release_session(const std::string& key) {
509 std::size_t released = 0;
510 for (const auto& [name, server] : servers_) {
511 auto* owner = dynamic_cast<SessionStateOwner*>(server.get());
512 if (owner != nullptr && owner->release_session(key)) {
513 ++released;
514 }
515 }
516 logger->info("Released session '{}' tool state on {} server(s)",
517 key, released);
518 return released;
519}
520
529std::vector<std::string> ServerManager::server_names() const {
530 std::vector<std::string> names;
531 for (const auto& [name, _] : servers_) {
532 names.push_back(name);
533 }
534 for (const auto& [name, _] : plugin_servers_) { // gh#133 (v2.10.1)
535 names.push_back(name);
536 }
537 for (const auto& [name, _] : external_snapshot()) {
538 names.push_back(name);
539 }
540 return names;
541}
542
559 const std::string& tool_name) const {
560 auto prefix = extract_prefix(tool_name);
561 auto local_name = extract_local_name(tool_name);
562 auto it = servers_.find(prefix);
563 if (it != servers_.end()) {
564 auto* tool = it->second->registry().get_tool(local_name);
565 return (tool != nullptr) ? tool->definition().input_schema
566 : std::string{};
567 }
568 // gh#133 (v2.10.1): without this a plugin's declared inputSchema would be
569 // invisible to ToolExecutor::check_schema, which skips validation on an
570 // empty schema — plugin tools would silently forgo the argument checking
571 // every built-in server's tools get.
572 return plugin_tool_schema(prefix, local_name);
573}
574
590std::string ServerManager::plugin_tool_schema(
591 const std::string& prefix,
592 const std::string& local_name) const {
593
594 auto it = plugin_servers_.find(prefix);
595 if (it == plugin_servers_.end()) {
596 return "";
597 }
598 try {
599 auto tools = nlohmann::json::parse(it->second->list_tools());
600 for (const auto& tool : tools) {
601 if (tool.value("name", std::string{}) == local_name) {
602 return tool.value("inputSchema",
603 nlohmann::json::object()).dump();
604 }
605 }
606 } catch (const std::exception& e) {
607 logger->error("Plugin '{}': cannot read schema for tool '{}': {}",
608 prefix, local_name, e.what());
609 }
610 return "";
611}
612
639std::string ServerManager::route_tool_call(
640 const std::string& tool_name,
641 const std::string& args_json) {
642
643 auto prefix = extract_prefix(tool_name);
644 auto local_name = extract_local_name(tool_name);
645
646 // Try in-process server first
647 auto it = servers_.find(prefix);
648 if (it != servers_.end()) {
649 ToolRootShared root(*root_lock_, tool_name);
650 return it->second->execute(local_name, args_json);
651 }
652
653 // gh#133 (v2.10.1): try a loaded plugin
654 auto plug_it = plugin_servers_.find(prefix);
655 if (plug_it != plugin_servers_.end()) {
656 ToolRootShared root(*root_lock_, tool_name);
657 return route_plugin_call(*plug_it->second, local_name, args_json);
658 }
659
660 return route_external_or_unknown(prefix, tool_name, local_name, args_json);
661}
662
680std::string ServerManager::route_external_or_unknown(
681 const std::string& prefix,
682 const std::string& tool_name,
683 const std::string& local_name,
684 const std::string& args_json) {
685
686 // gh#171: our own reference keeps the client alive for the whole call,
687 // even if a disconnect removes it from the registry meanwhile.
688 if (auto client = find_external(prefix)) {
689 return route_external_call(client.get(), tool_name, local_name,
690 args_json);
691 }
692
693 logger->warn("Unknown server: {}", prefix);
694 nlohmann::json resp;
695 resp["result"] = "Error: Unknown server '" + prefix + "'";
696 resp["directives"] = nlohmann::json::array();
697 return resp.dump();
698}
699
713std::string ServerManager::route_external_call(
714 ExternalMCPClient* client,
715 const std::string& tool_name,
716 const std::string& local_name,
717 const std::string& args_json) {
718
719 if (!client->is_connected()) {
720 auto prefix = extract_prefix(tool_name);
721 return disconnected_error(tool_name, prefix);
722 }
723 return client->execute(local_name, args_json);
724}
725
744std::string ServerManager::route_plugin_call(
745 PluginServer& plugin,
746 const std::string& local_name,
747 const std::string& args_json) {
748
749 std::string raw;
750 try {
751 raw = plugin.execute(local_name, args_json);
752 auto parsed = nlohmann::json::parse(raw);
753 if (!parsed.is_object() || !parsed.contains("result")) {
754 throw std::runtime_error("missing 'result' field");
755 }
756 return raw;
757 } catch (const std::exception& e) {
758 logger->error("Plugin '{}' returned an invalid response for tool "
759 "'{}': {} — raw response: {}",
760 plugin.name(), local_name, e.what(), raw);
761 nlohmann::json resp;
762 resp["result"] = "Error: plugin '" + plugin.name() +
763 "' returned an invalid response: " + e.what();
764 resp["directives"] = nlohmann::json::array();
765 return resp.dump();
766 }
767}
768
784 const std::string& tool_name,
785 const std::string& args_json) const {
786 auto pattern = tool_name + ":" + args_to_pattern(args_json);
787 return permissions_.is_allowed(tool_name, pattern);
788}
789
806 const std::string& tool_name,
807 const std::string& args_json) const {
808 auto prefix = extract_prefix(tool_name);
809 auto it = servers_.find(prefix);
810 if (it != servers_.end()) {
811 return it->second->get_permission_pattern(
812 tool_name, args_json);
813 }
814 return tool_name;
815}
816
827 const std::string& tool_name) const {
828 auto prefix = extract_prefix(tool_name);
829 auto it = servers_.find(prefix);
830 if (it != servers_.end()) {
831 auto local = extract_local_name(tool_name);
832 return it->second->skip_duplicate_check(local);
833 }
834 return false;
835}
836
850 const std::string& tool_name) const {
851 auto prefix = extract_prefix(tool_name);
852 auto it = servers_.find(prefix);
853 if (it != servers_.end()) {
854 auto local = extract_local_name(tool_name);
855 auto* tool = it->second->registry().get_tool(local);
856 if (tool != nullptr) {
857 return tool->required_access_level();
858 }
859 }
860 return MCPAccessLevel::WRITE; // Safe default
861}
862
876 const std::string& pattern, bool allow) {
877 permissions_.add_permission(pattern, allow);
878}
879
893 // Stop health monitor first
894 if (health_monitor_) {
895 health_monitor_->stop();
896 }
897
898 // Disconnect external clients — taken out under the lock, disconnected
899 // after it (gh#171: never I/O while holding external_mutex_).
900 std::map<std::string, std::shared_ptr<ExternalMCPClient>> clients;
901 {
902 std::unique_lock<std::shared_mutex> lock(*external_mutex_);
903 clients.swap(external_clients_);
904 server_info_.clear();
905 }
906 for (auto& [name, client] : clients) {
907 client->disconnect();
908 }
909
910 // gh#133 (v2.10.1): destroy plugin instances and dlclose their libraries
911 // before the in-process servers, so nothing can call into an unmapped .so.
912 logger->info("Unloading {} MCP plugin(s)", plugin_servers_.size());
913 plugin_servers_.clear();
914
915 // Destroy in-process servers
916 logger->info("Shutting down {} MCP servers", servers_.size());
917 servers_.clear();
918}
919
929std::string ServerManager::extract_prefix(
930 const std::string& tool_name) {
931 auto dot = tool_name.find('.');
932 if (dot == std::string::npos) {
933 return tool_name;
934 }
935 return tool_name.substr(0, dot);
936}
937
947std::string ServerManager::extract_local_name(
948 const std::string& tool_name) {
949 auto dot = tool_name.find('.');
950 if (dot == std::string::npos) {
951 return tool_name;
952 }
953 return tool_name.substr(dot + 1);
954}
955
969std::string ServerManager::args_to_pattern(
970 const std::string& args_json) {
971 if (args_json.empty() || args_json == "{}") {
972 return "*";
973 }
974 try {
975 auto j = nlohmann::json::parse(args_json);
976 if (j.is_object() && !j.empty()) {
977 auto first = j.begin();
978 if (first->is_string()) {
979 return first->get<std::string>();
980 }
981 }
982 } catch (...) {
983 // Parse failure — treat as wildcard
984 }
985 return "*";
986}
987
988// ── v1.8.7: External server methods ─────────────────────
989
997 mcp_config_ = config;
998}
999
1011 for (auto& [_, client] : external_snapshot()) {
1012 if (client) { client->interrupt(); }
1013 }
1014}
1015
1030 for (auto& [_, client] : external_snapshot()) {
1031 if (client) { client->clear_interrupt(); }
1032 }
1033}
1034
1045void ServerManager::initialize_external_servers() {
1046 connect_declared_servers();
1047 connect_discovered_servers();
1048
1049 health_monitor_ = std::make_unique<HealthMonitor>(
1050 ReconnectPolicy(mcp_config_.reconnect),
1051 mcp_config_.health_check_interval_ms);
1052 for (auto& [name, client] : external_clients_) {
1053 health_monitor_->watch(name, client.get());
1054 }
1055 if (!external_clients_.empty()) {
1056 health_monitor_->start();
1057 }
1058 logger->info("External MCP: {} servers connected",
1059 external_clients_.size());
1060}
1061
1072void ServerManager::connect_declared_servers() {
1073 for (const auto& [name, entry] : mcp_config_.external_servers) {
1074 ExternalServerConfig probe;
1075 probe.name = name;
1076 probe.transport = entry.command.empty() ? "sse" : "stdio";
1077 probe.command = entry.command;
1078 probe.args = entry.args;
1079 if (launches_own_bridge(probe, own_bridge_socket_, project_dir_)) {
1080 logger->warn("[mcp] declared server '{}' would connect to this "
1081 "handle's own bridge ({}) — refused (gh#200)",
1082 name, own_bridge_socket_.string());
1083 continue;
1084 }
1085 auto client = create_external_client(name, entry);
1086 connect_and_register_external(name, std::move(client),
1087 "config", entry.url, entry.command);
1088 }
1089}
1090
1096void ServerManager::connect_discovered_servers() {
1097 mcp_json_discovery_ = std::make_unique<MCPJsonDiscovery>(project_dir_);
1098 std::set<std::string> existing;
1099 for (const auto& [name, _] : servers_) { existing.insert(name); }
1100 for (const auto& [name, _] : external_clients_) { existing.insert(name); }
1101
1102 for (const auto& cfg : mcp_json_discovery_->discover(existing)) {
1103 if (!admit_discovered(cfg)) { continue; }
1104 auto client = create_external_client(cfg);
1105 connect_and_register_external(cfg.name, std::move(client),
1106 "mcp_json", cfg.url, cfg.command);
1107 }
1108}
1109
1123bool ServerManager::admit_discovered(const ExternalServerConfig& cfg) const {
1124 const char* why = nullptr;
1125 if (launches_own_bridge(cfg, own_bridge_socket_, project_dir_)) {
1126 why = "it would connect to this handle's own bridge (gh#200)";
1127 } else {
1128 switch (mcpjson_config_verdict(cfg.name, mcp_config_)) {
1130 why = "it is listed in mcp.disabled_mcpjson_servers";
1131 break;
1133 break;
1135 if (discovered_approver_ == nullptr) {
1136 why = "it is not enabled and no PRE_MCP_SERVER_CONNECT hook "
1137 "is registered to approve it";
1138 } else if (!discovered_approver_(
1139 discovered_server_json(cfg, project_dir_),
1140 discovered_approver_ud_)) {
1141 why = "the PRE_MCP_SERVER_CONNECT hook refused it";
1142 }
1143 break;
1144 }
1145 }
1146 if (why != nullptr) {
1147 logger->info("[mcp] discovered server '{}' not connected: {}",
1148 cfg.name, why);
1149 }
1150 return why == nullptr;
1151}
1152
1161 DiscoveredServerApprover fn, void* user_data) {
1162 discovered_approver_ = fn;
1163 discovered_approver_ud_ = user_data;
1164}
1165
1172void ServerManager::set_own_bridge_socket(const std::filesystem::path& socket) {
1173 own_bridge_socket_ = socket;
1174}
1175
1188std::shared_ptr<ExternalMCPClient>
1189ServerManager::connect_and_register_external(
1190 const std::string& name,
1191 std::unique_ptr<ExternalMCPClient> client,
1192 const std::string& source,
1193 const std::string& url,
1194 const std::string& command) {
1195
1196 ServerInfo info;
1197 info.name = name;
1198 info.transport = url.empty() ? "stdio" : "sse";
1199 info.url = url;
1200 info.command = command;
1201 info.source = source;
1202 info.status = "disconnected";
1203
1204 bool ok = client->connect();
1205 if (ok) {
1206 info.status = "connected";
1207 info.connected_at = std::chrono::system_clock::now();
1208 } else {
1209 info.status = "error";
1210 logger->error("Failed to connect external server '{}'", name);
1211 }
1212
1213 // gh#171: the connect above ran with no lock; registration re-checks the
1214 // name under it, because another thread may have registered it meanwhile.
1215 std::shared_ptr<ExternalMCPClient> shared = std::move(client);
1216 bool inserted = false;
1217 {
1218 std::unique_lock<std::shared_mutex> lock(*external_mutex_);
1219 inserted = external_clients_.emplace(name, shared).second;
1220 if (inserted) { server_info_[name] = info; }
1221 }
1222 if (!inserted) {
1223 logger->warn("External server '{}' was registered concurrently — "
1224 "refusing the second connection", name);
1225 shared->disconnect();
1226 shared.reset();
1227 }
1228 return shared;
1229}
1230
1247std::unique_ptr<Transport> ServerManager::make_transport(
1248 const ExternalServerConfig& spec) const {
1249 bool prefer_sse = (spec.transport == "sse")
1250 || (!spec.url.empty() && spec.command.empty());
1251 if (prefer_sse) {
1252 return std::make_unique<SSETransport>(spec.url);
1253 }
1254 // gh#19 (v2.1.5): pass the registered server name as the display
1255 // label so child stderr lines and lifecycle logs identify the
1256 // server, not the resolved spawn command (which collides when
1257 // multiple servers share an entrypoint like /usr/bin/env python).
1258 auto transport = std::make_unique<StdioTransport>(
1259 spec.name, spec.command, spec.args, spec.env,
1260 /*default_timeout_ms=*/30000U);
1261 // gh#166 (v2.13.0): spawn the child IN the repository its tools are
1262 // supposed to serve. An explicit spec value wins; otherwise the
1263 // manager's own root, which for a workspace is the workspace root.
1264 transport->set_working_dir(spec.working_dir.empty()
1265 ? project_dir_.string()
1266 : spec.working_dir);
1267 return transport;
1268}
1269
1286 const ExternalServerConfig& spec) {
1287
1288 if (servers_.count(spec.name) > 0 || find_external(spec.name)) {
1289 logger->warn("Server '{}' already registered", spec.name);
1290 return {};
1291 }
1292
1293 auto client = create_external_client(spec); // gh#175: one factory
1294
1295 // gh#171: the registered client comes back from registration rather than
1296 // being re-read with operator[], which INSERTED, unlocked, on a miss.
1297 auto registered = connect_and_register_external(
1298 spec.name, std::move(client), "runtime", spec.url, spec.command);
1299 if (!registered) { return {}; }
1300 if (health_monitor_) {
1301 health_monitor_->watch(spec.name, registered.get());
1302 }
1303
1304 // Parse tool names from cached list
1305 std::vector<std::string> tool_names;
1306 auto tools_json = registered->list_tools();
1307 try {
1308 auto tools = nlohmann::json::parse(tools_json);
1309 for (const auto& t : tools) {
1310 tool_names.push_back(t["name"].get<std::string>());
1311 }
1312 } catch (...) {}
1313
1314 return tool_names;
1315}
1316
1327 const std::string& name,
1328 const std::string& command,
1329 const std::vector<std::string>& args,
1330 const std::string& url) {
1332 spec.name = name;
1333 spec.command = command;
1334 spec.args = args;
1335 spec.url = url;
1336 return connect_external_server(spec);
1337}
1338
1346 const std::string& name) {
1347
1348 std::shared_ptr<ExternalMCPClient> client;
1349 {
1350 std::unique_lock<std::shared_mutex> lock(*external_mutex_);
1351 auto it = external_clients_.find(name);
1352 if (it != external_clients_.end()) {
1353 client = std::move(it->second);
1354 external_clients_.erase(it);
1355 server_info_.erase(name);
1356 }
1357 }
1358 if (!client) {
1359 logger->warn("External server '{}' not found", name);
1360 return;
1361 }
1362 if (health_monitor_) {
1363 health_monitor_->unwatch(name);
1364 }
1365 // gh#171: a call already routed to this client still holds its own
1366 // reference; the client is destroyed when the last one drops.
1367 client->disconnect();
1368
1369 logger->info("External server '{}' disconnected", name);
1370}
1371
1382std::map<std::string, ServerInfo>
1384 std::map<std::string, ServerInfo> result;
1385 {
1386 std::shared_lock<std::shared_mutex> lock(*external_mutex_);
1387 result = server_info_;
1388 }
1389
1390 // Add in-process servers
1391 for (const auto& [name, _] : servers_) {
1392 if (result.count(name) == 0) {
1393 ServerInfo info;
1394 info.name = name;
1395 info.transport = "in_process";
1396 info.status = "connected";
1397 info.source = "builtin";
1398 result[name] = info;
1399 }
1400 }
1401
1402 // gh#133 (v2.10.1): plugins report their own transport so `entropic
1403 // inspect` distinguishes them from builtins and external processes.
1404 for (const auto& [name, plugin] : plugin_servers_) {
1405 if (result.count(name) == 0) {
1406 ServerInfo info;
1407 info.name = name;
1408 info.transport = "plugin";
1409 info.status = "connected";
1410 info.source = "plugin";
1411 info.command = plugin->path().string();
1412 result[name] = info;
1413 }
1414 }
1415 return result;
1416}
1417
1428 if (health_monitor_) {
1429 health_monitor_->process_events();
1430 }
1431}
1432
1441static std::string expand_workspace_root(std::string s, const std::string& root) {
1442 static const std::string key = "${ENTROPIC_WORKSPACE_ROOT}";
1443 for (auto p = s.find(key); p != std::string::npos;
1444 p = s.find(key, p + root.size())) {
1445 s.replace(p, key.size(), root);
1446 }
1447 return s;
1448}
1449
1469std::unique_ptr<ExternalMCPClient>
1470ServerManager::create_external_client(
1471 const std::string& name,
1472 const ExternalServerEntry& entry) {
1473 const auto root = project_dir_.string();
1474 ExternalServerConfig spec;
1475 spec.name = name;
1476 spec.command = entry.command;
1477 for (const auto& a : entry.args) { spec.args.push_back(expand_workspace_root(a, root)); }
1478 for (const auto& [k, v] : entry.env) { spec.env[k] = expand_workspace_root(v, root); }
1479 spec.url = entry.url;
1480 spec.transport = entry.url.empty() ? "stdio" : "sse";
1481 return create_external_client(spec);
1482}
1483
1494std::unique_ptr<ExternalMCPClient>
1495ServerManager::create_external_client(
1496 const ExternalServerConfig& config) {
1497 auto client = std::make_unique<ExternalMCPClient>(
1498 config.name, make_transport(config));
1499 // gh#175: mcp.server_timeout_seconds bounds bring-up. Every client is
1500 // built here — declared, discovered and runtime — so none can miss it.
1501 client->set_init_timeout_ms(
1502 static_cast<uint32_t>(mcp_config_.server_timeout_seconds) * 1000u);
1503 return client;
1504}
1505
1520std::string ServerManager::disconnected_error(
1521 const std::string& tool_name,
1522 const std::string& server_name) {
1523
1524 nlohmann::json resp;
1525 resp["result"] = "Server '" + server_name +
1526 "' is disconnected. Tool '" + tool_name +
1527 "' is unavailable. Use a different approach "
1528 "or try again later.";
1529 resp["directives"] = nlohmann::json::array();
1530 resp["is_error"] = true;
1531 return resp.dump();
1532}
1533
1540std::vector<std::pair<std::string, std::shared_ptr<ExternalMCPClient>>>
1541ServerManager::external_snapshot() const {
1542 std::shared_lock<std::shared_mutex> lock(*external_mutex_);
1543 return {external_clients_.begin(), external_clients_.end()};
1544}
1545
1553std::shared_ptr<ExternalMCPClient>
1554ServerManager::find_external(const std::string& name) const {
1555 std::shared_lock<std::shared_mutex> lock(*external_mutex_);
1556 auto it = external_clients_.find(name);
1557 return it == external_clients_.end() ? nullptr : it->second;
1558}
1559
1560} // namespace entropic
Bash MCP server — shell command execution under a timeout.
Filesystem MCP server with read-before-write enforcement.
Definition filesystem.h:153
void set_outside_root_approver(OutsideRootApprover fn, void *user_data)
Install the approver consulted under optional (v2.13.0).
Concrete base class for MCP servers (80% logic).
Definition server_base.h:66
bool is_denied(const std::string &tool_name, const std::string &pattern) const
Check if a tool call is explicitly denied.
void add_permission(const std::string &pattern, bool allow)
Add a permission pattern at runtime.
bool is_allowed(const std::string &tool_name, const std::string &pattern) const
Check if a tool call is explicitly allowed (skip prompting).
static entropic_error_t load(const std::filesystem::path &path, std::unique_ptr< PluginServer > &out)
Load a plugin .so and construct its server instance.
Exponential backoff with jitter for reconnection attempts.
std::vector< std::string > connect_external_server(const ExternalServerConfig &spec)
Connect to an external MCP server at runtime (canonical spec-based API).
void init_builtins(const MCPConfig &mcp_config, const std::vector< std::string > &tier_names, const std::string &data_dir, const std::vector< std::string > &require_context_tiers={})
Register built-in servers based on config flags.
size_t enter_working_dir(const std::filesystem::path &dir)
Re-root every in-process and plugin server for a sandboxed delegation, and KEEP the root lock (gh#158...
bool(*)(const std::string &context_json, void *user_data) DiscoveredServerApprover
Approver consulted for a discovered .mcp.json server that config neither disabled nor pre-approved (v...
MCPAccessLevel get_required_access_level(const std::string &tool_name) const
Get the required access level for a tool.
bool is_explicitly_allowed(const std::string &tool_name, const std::string &args_json) const
Check if tool is explicitly allowed (skip prompting).
std::map< std::string, ServerInfo > list_server_info() const
Get snapshot of all servers with current status.
std::vector< std::string > server_names() const
List registered server names (in-process + external).
void process_health_events()
Process pending health events (call from engine loop).
MCPServerBase * get_server(const std::string &name) const
Get a registered in-process server by name.
std::string list_tools() const
List all tools from all connected servers.
std::vector< std::string > external_tools_without_readonly_hint(const std::vector< std::string > &allowed) const
External tools that do not declare readOnlyHint: true.
size_t leave_working_dir(const std::filesystem::path &dir)
Restore after enter_working_dir(), then release one level of the root lock (gh#158,...
void clear_external_tool_interrupts()
Release the interrupt on every external client (gh#150).
std::size_t release_session(const std::string &key)
Release every in-process server's state for one session (gh#158).
ServerManager(const PermissionsConfig &permissions, const std::filesystem::path &project_dir)
Construct with permission config and project directory.
void register_server(std::unique_ptr< MCPServerBase > server)
Register a built-in server (in-process, ownership transferred).
void add_permission(const std::string &pattern, bool allow)
Add a runtime permission pattern.
entropic_error_t load_plugins(const MCPConfig &mcp_config)
Load the dlopen plugins listed in mcp.plugins (gh#133).
bool skip_duplicate_check(const std::string &tool_name) const
Check if tool should skip duplicate detection.
void set_discovered_server_approver(DiscoveredServerApprover fn, void *user_data)
Install the discovered-server approver (v2.14.0, gh#199).
std::string get_permission_pattern(const std::string &tool_name, const std::string &args_json) const
Generate permission pattern via server class delegation.
std::string get_tool_schema(const std::string &tool_name) const
Get the JSON Schema for a tool's input parameters.
void interrupt_external_tools()
Abort in-flight tool calls across every external MCP client.
std::string execute(const std::string &tool_name, const std::string &args_json)
Execute a tool call via the appropriate server.
void set_mcp_config(const MCPConfig &config)
Set MCP config for external server initialization.
size_t set_working_dir_all(const std::filesystem::path &dir)
Point every in-process and plugin server at dir (gh#160).
bool set_outside_root_approver(OutsideRootApprover fn, void *user_data)
Install the outside-root approver on this set's filesystem server (v2.13.0).
static std::vector< std::string > tools_without_readonly_hint(const std::string &tools_json, const std::vector< std::string > &allowed)
The readOnlyHint filter itself, over one descriptor list.
void shutdown()
Shutdown all servers (in-process + external).
void disconnect_external_server(const std::string &name)
Disconnect and remove an external server.
void initialize()
Initialize all registered servers.
void set_own_bridge_socket(const std::filesystem::path &socket)
The socket this handle's external bridge binds (v2.14.0, gh#200).
A server that keeps state per session and can release it (gh#158).
RAII shared hold on a ToolRootLock (owner pass-through aware).
Diagnostics MCP server — LSP client for code diagnostics.
Which external MCP servers a server set may launch (v2.14.0).
Entropic MCP server — engine-level tools including introspection.
entropic_error_t
Error codes returned by all C API functions.
Definition error.h:37
@ ENTROPIC_OK
Success.
Definition error.h:38
@ ENTROPIC_ERROR_PLUGIN_LOAD_FAILED
dlopen/dlsym failed on plugin .so
Definition error.h:48
Filesystem MCP server — read/write/edit/glob/grep/list_directory.
Git MCP server — version control operations.
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).
@ disabled
Named in disabled_mcpjson_servers — never connect.
@ ask
Config is silent; the pre-connect hook decides.
@ enabled
Named in enabled_mcpjson_servers, or enable-all is set.
static bool tool_is_read_only(const nlohmann::json &tool)
Whether one tool descriptor asserts readOnlyHint: true (gh#160).
@ ok
Tool dispatched, returned non-empty content.
bool launches_own_bridge(const ExternalServerConfig &cfg, const std::filesystem::path &own_socket, const std::filesystem::path &spawn_root)
Whether launching cfg would connect this handle to itself.
@ allow
true: every escape served, no prompt
@ optional
optional: every escape asks the host's path approver
static std::string expand_workspace_root(std::string s, const std::string &root)
Replace every ${ENTROPIC_WORKSPACE_ROOT} with root (v2.14.1).
static void append_prefixed_tools(const std::string &tools_json, const std::string &prefix, nlohmann::json &all)
Append a server's tools to an array, prefixing each name.
OutsideRootVerdict(*)(const OutsideRootRequest &request, void *user_data) OutsideRootApprover
Approver the server consults under allow_outside_root: optional.
Definition filesystem.h:134
MCPAccessLevel
MCP tool access level for per-identity authorization.
Definition config.h:38
@ WRITE
Read + write operations (e.g., write_file, execute)
McpJsonConfigVerdict mcpjson_config_verdict(const std::string &name, const MCPConfig &cfg)
Config's verdict on one discovered server.
@ full
-1 or >= 99: the whole file is resident.
std::string discovered_server_json(const ExternalServerConfig &cfg, const std::filesystem::path &root)
The pre-connect hook's context JSON for one discovered server.
std::string config
–config FILE
Definition serve.cpp:38
MCP server lifecycle management and tool routing.
int timeout_seconds
Per-command wall-clock limit (>= 1)
Definition config.h:924
Parsed server entry from .mcp.json.
std::string command
Stdio command (empty for SSE)
std::vector< std::string > args
Stdio command args.
std::string url
SSE URL (empty for stdio)
MCP server configuration.
Definition config.h:953
ReconnectConfig reconnect
Reconnection backoff policy.
Definition config.h:1020
bool enable_entropic
Enable entropic internal server (handoff, delegate, pipeline)
Definition config.h:954
std::vector< std::filesystem::path > plugins
In-process MCP server plugin .so paths (gh#133, v2.10.1).
Definition config.h:987
FilesystemConfig filesystem
Filesystem server config.
Definition config.h:960
bool enable_filesystem
Enable filesystem server.
Definition config.h:955
BashConfig bash
Bash server config (v2.13.0)
Definition config.h:961
bool enable_git
Enable git server.
Definition config.h:957
bool enable_diagnostics
Diagnostics server — placeholder, off by default (v2.14.0, gh#174)
Definition config.h:958
int server_timeout_seconds
Seconds an external MCP server gets to come up: the initialize handshake and its tools/list,...
Definition config.h:975
uint32_t health_check_interval_ms
Ping interval (0 = disabled)
Definition config.h:1021
bool enable_bash
Enable bash server.
Definition config.h:956
bool enable_web
Web server — placeholder, off by default (v2.14.0, gh#174)
Definition config.h:959
Tool permission configuration.
Definition config.h:754
Runtime state of a connected MCP server.
SSE transport for external MCP servers.
Stdio transport for external MCP servers.
Web MCP server — web_fetch + web_search.