Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
external_client.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
10
11#include <nlohmann/json.hpp>
12
13#include <algorithm>
14#include <set>
15
16static auto logger = entropic::log::get("mcp.external_client");
17
18namespace entropic {
19
28 std::string name,
29 std::unique_ptr<Transport> transport)
30 : name_(std::move(name)),
31 transport_(std::move(transport)) {}
32
46 if (!transport_->open()) {
47 logger->error("Transport open failed for '{}'", name_);
48 return false;
49 }
50
51 if (!send_initialize()) {
52 logger->error("MCP initialize failed for '{}'", name_);
53 transport_->close();
54 return false;
55 }
56
57 if (!query_tools()) {
58 logger->warn("tools/list failed for '{}' — "
59 "connected with 0 tools", name_);
60 }
61
62 logger->info("Connected to '{}': {} tools",
63 name_, cached_tool_names_.size());
64 return true;
65}
66
73 transport_->close();
74 std::lock_guard<std::mutex> lock(tools_mutex_);
75 cached_tools_json_ = "[]";
76 cached_tool_names_.clear();
77 logger->info("Disconnected from '{}'", name_);
78}
79
89std::string ExternalMCPClient::list_tools() const {
90 std::lock_guard<std::mutex> lock(tools_mutex_);
91 return cached_tools_json_;
92}
93
112std::string ExternalMCPClient::empty_response_envelope(
113 const std::string& tool_name) const {
114 if (transport_->is_interrupted()) {
115 return build_response(
116 "Error: tool '" + name_ + "." + tool_name +
117 "' was not attempted — the transport is interrupted. "
118 "The interrupt is released at the start of the next run.",
119 true);
120 }
121 return build_response(
122 "Error: tool '" + name_ + "." + tool_name +
123 "' timed out or the transport failed.", true);
124}
125
141 const std::string& tool_name,
142 const std::string& args_json) {
143
144 if (!transport_->is_connected()) {
145 // gh#150: the leading "Error:" is load-bearing, not decoration.
146 // classify_tool_result routes on the TEXT via looks_like_tool_error,
147 // which matches a leading "error"/"[error]"/JSON-error shape. This
148 // message used to start with "Server '...'", so a failed call was
149 // logged status=ok and counted as a success — the reporting
150 // consumer watched every external call fail while the logs said
151 // everything was fine.
152 return build_response(
153 "Error: server '" + name_ + "' is disconnected. "
154 "Tool '" + name_ + "." + tool_name + "' unavailable.",
155 true);
156 }
157
158 nlohmann::json params;
159 params["name"] = tool_name;
160 try {
161 params["arguments"] = nlohmann::json::parse(args_json);
162 } catch (...) {
163 params["arguments"] = nlohmann::json::object();
164 }
165
166 int request_id = 0;
167 auto request = build_request_id(
168 "tools/call", params.dump(), request_id);
169 auto response = transport_->send_request(
170 request, DEFAULT_TIMEOUT_MS);
171
172 auto problem = response_problem(tool_name, response, request_id);
173 if (!problem.empty()) {
174 return problem;
175 }
176
177 auto result_text = extract_tool_result(response);
178 return build_response(result_text);
179}
180
199std::string ExternalMCPClient::response_problem(
200 const std::string& tool_name,
201 const std::string& response,
202 int expected_id) const {
203 if (response.empty()) {
204 return empty_response_envelope(tool_name);
205 }
206 if (!response_matches(response, expected_id)) {
207 return mismatched_response_envelope(tool_name, expected_id);
208 }
209 return {};
210}
211
230std::string ExternalMCPClient::mismatched_response_envelope(
231 const std::string& tool_name, int expected_id) const {
232 logger->error("Server '{}' answered request {} with a response carrying "
233 "a different id; discarding it rather than returning "
234 "another request's result for tool '{}'",
235 name_, expected_id, tool_name);
236 return build_response(
237 "Error: tool '" + name_ + "." + tool_name +
238 "' got a response that did not answer it (JSON-RPC id mismatch). "
239 "The transport had a stale reply queued from an abandoned request; "
240 "it has been discarded. Retry the call.", true);
241}
242
251static std::vector<std::string> names_diff(
252 const std::set<std::string>& a, const std::set<std::string>& b) {
253 std::vector<std::string> out;
254 std::set_difference(a.begin(), a.end(), b.begin(), b.end(),
255 std::back_inserter(out));
256 return out;
257}
258
270std::pair<std::vector<std::string>, std::vector<std::string>>
272 auto snapshot = [this] {
273 std::lock_guard<std::mutex> lock(tools_mutex_);
274 return std::set<std::string>(cached_tool_names_.begin(),
275 cached_tool_names_.end());
276 };
277
278 std::set<std::string> old_names = snapshot();
279 query_tools();
280 std::set<std::string> new_names = snapshot();
281
284
285 logger->info("Server '{}' tools refreshed: +{} -{}",
286 name_, added.size(), removed.size());
287 return {added, removed};
288}
289
297 return transport_->is_connected();
298}
299
310std::string ExternalMCPClient::build_request(
311 const std::string& method,
312 const std::string& params) {
313 int ignored = 0;
314 return build_request_id(method, params, ignored);
315}
316
328std::string ExternalMCPClient::build_request_id(
329 const std::string& method,
330 const std::string& params,
331 int& id) {
332
333 id = next_id_.fetch_add(1, std::memory_order_relaxed);
334 nlohmann::json req;
335 req["jsonrpc"] = "2.0";
336 req["id"] = id;
337 req["method"] = method;
338 try {
339 req["params"] = nlohmann::json::parse(params);
340 } catch (...) {
341 req["params"] = nlohmann::json::object();
342 }
343 return req.dump();
344}
345
356bool ExternalMCPClient::response_matches(
357 const std::string& response_json, int expected_id) {
358 try {
359 auto j = nlohmann::json::parse(response_json);
360 // A string id is accepted when it spells the same number. We always
361 // SEND an integer and JSON-RPC 2.0 requires the response to echo the
362 // same value, but stringifying it is the one deviation common enough
363 // in the wild to be worth tolerating — it is unambiguous, so
364 // rejecting it would break working servers for no safety gain.
365 if (j.value("id", nlohmann::json()).is_string()) {
366 return j["id"].get<std::string>()
367 == std::to_string(expected_id);
368 }
369 return j.contains("id") && j["id"].is_number_integer()
370 && j["id"].get<int>() == expected_id;
371 } catch (const nlohmann::json::exception&) {
372 return false;
373 }
374}
375
383bool ExternalMCPClient::validate_init_response(
384 const std::string& response) {
385
386 try {
387 auto j = nlohmann::json::parse(response);
388 if (j.contains("error")) {
389 logger->error("Initialize error from '{}': {}",
390 name_, j["error"].dump());
391 return false;
392 }
393 return true;
394 } catch (...) {
395 return false;
396 }
397}
398
408bool ExternalMCPClient::send_initialize() {
409 nlohmann::json params;
410 params["protocolVersion"] = "2024-11-05";
411 params["capabilities"] = nlohmann::json::object();
412 params["clientInfo"]["name"] = "entropic";
413 params["clientInfo"]["version"] = "1.8.7";
414
415 int request_id = 0;
416 auto request = build_request_id(
417 "initialize", params.dump(), request_id);
418 auto response = transport_->send_request(
419 request, init_timeout_ms_);
420
421 if (response.empty() || !response_matches(response, request_id)) {
422 return false;
423 }
424 return validate_init_response(response);
425}
426
440bool ExternalMCPClient::query_tools() {
441 int request_id = 0;
442 auto request = build_request_id("tools/list", "{}", request_id);
443 auto response = transport_->send_request(
444 request, init_timeout_ms_);
445
446 if (response.empty() || !response_matches(response, request_id)) {
447 return false;
448 }
449
450 try {
451 auto j = nlohmann::json::parse(response);
452 auto tools = j.at("result").at("tools");
453
454 // Prefix tool names with server name
455 std::vector<std::string> names;
456 for (auto& tool : tools) {
457 std::string orig = tool["name"].get<std::string>();
458 tool["name"] = name_ + "." + orig;
459 names.push_back(tool["name"].get<std::string>());
460 }
461
462 std::lock_guard<std::mutex> lock(tools_mutex_);
463 cached_tools_json_ = tools.dump();
464 cached_tool_names_ = std::move(names);
465 return true;
466 } catch (const nlohmann::json::exception& e) {
467 logger->error("Failed to parse tools/list from '{}': {}",
468 name_, e.what());
469 return false;
470 }
471}
472
487std::string ExternalMCPClient::extract_tool_result(
488 const std::string& response_json) {
489 std::string text;
490 try {
491 auto j = nlohmann::json::parse(response_json);
492 if (j.contains("error")) {
493 return "Error: " + j["error"]["message"].get<std::string>();
494 }
495 const auto& result = j.at("result");
496 for (const auto& item : result.at("content")) {
497 if (item.value("type", "") == "text") {
498 text += item.at("text").get<std::string>();
499 }
500 }
501 if (result.value("isError", false)) { text = "Error: " + text; }
502 } catch (const nlohmann::json::exception& e) {
503 text = "Error parsing response: " + std::string(e.what());
504 }
505 return text;
506}
507
525std::string ExternalMCPClient::build_response(
526 const std::string& result_text,
527 bool is_error) {
528
529 nlohmann::json resp;
530 resp["result"] = result_text;
531 // SECURITY: External servers CANNOT inject directives.
532 // Directives array is always empty for external tool results.
533 resp["directives"] = nlohmann::json::array();
534 if (is_error) {
535 resp["is_error"] = true;
536 }
537 return resp.dump();
538}
539
547 if (ms > 0) { init_timeout_ms_ = ms; }
548}
549
550} // namespace entropic
std::string list_tools() const
List tools as JSON array string (cached).
std::pair< std::vector< std::string >, std::vector< std::string > > refresh_tools()
Re-query tools/list and diff against cache.
void set_init_timeout_ms(uint32_t ms)
Bound for bringing the server up: initialize + tools/list (gh#175, v2.14.0).
bool is_connected() const
Check connection state.
bool connect()
Connect: open transport + MCP initialize + tools/list.
ExternalMCPClient(std::string name, std::unique_ptr< Transport > transport)
Construct with name and transport.
std::string execute(const std::string &tool_name, const std::string &args_json)
Execute a tool call via the external server.
void disconnect()
Disconnect: close transport.
Client for communicating with external MCP servers.
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).
@ optional
optional: every escape asks the host's path approver
@ request
GenerationParams::grammar (COMMON_GRAMMAR_TYPE_USER)
static std::vector< std::string > names_diff(const std::set< std::string > &a, const std::set< std::string > &b)
Names in a not in b (sorted set difference).