char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Xuan-Son Nguyencommon: add `subproc.h` wrapper, disabled on android/ios (#26102)d2a818231

char/aliceai-t5-moe
26.4 KiB820 linesraw
1#include "server-mcp.h"
2
3#include "subproc.h"
4
5#include <atomic>
6#include <chrono>
7#include <cstdio>
8#include <fstream>
9#include <functional>
10#include <sstream>
11#include <thread>
12
13#if defined(_WIN32)
14#  include <io.h>
15#  include <windows.h>
16#else
17#  include <errno.h>
18#  include <fcntl.h>
19#  include <poll.h>
20#  include <unistd.h>
21extern char ** environ;
22#endif
23
24// read NDJSON lines from a child pipe, calling on_line per line until `running` clears, EOF/error, or on_line returns false.
25// polled, not blocking: a grandchild can inherit the pipe's write end and hold it open (terminate() kills only the direct child), so a blocking read would hang teardown on an EOF that never comes.
26static void mcp_pump_ndjson(FILE * f, std::atomic<bool> & running,
27                            const std::function<bool(std::string &&)> & on_line) {
28    if (!f) {
29        return;
30    }
31    const int    poll_ms  = 50;
32    const size_t max_line = 8 * 1024 * 1024; // drop any single NDJSON line larger than this, so a child that never emits '\n' can't grow buf without bound
33#if defined(_WIN32)
34    HANDLE h = (HANDLE) _get_osfhandle(_fileno(f));
35#else
36    int fd = fileno(f);
37    int fl = fcntl(fd, F_GETFL, 0);
38    if (fl >= 0) {
39        fcntl(fd, F_SETFL, fl | O_NONBLOCK);
40    }
41#endif
42    std::string buf;
43    bool        skipping = false; // discarding an over-long line until its terminating newline
44    char        chunk[4096];
45    while (running.load()) {
46        size_t n = 0;
47#if defined(_WIN32)
48        DWORD avail = 0;
49        if (!PeekNamedPipe(h, NULL, 0, NULL, &avail, NULL)) {
50            break; // pipe broken / child gone
51        }
52        if (avail == 0) {
53            std::this_thread::sleep_for(std::chrono::milliseconds(poll_ms));
54            continue;
55        }
56        DWORD to_read = avail < (DWORD) sizeof(chunk) ? avail : (DWORD) sizeof(chunk);
57        DWORD got     = 0;
58        if (!ReadFile(h, chunk, to_read, &got, NULL) || got == 0) {
59            break;
60        }
61        n = (size_t) got;
62#else
63        struct pollfd pfd;
64        pfd.fd      = fd;
65        pfd.events  = POLLIN;
66        pfd.revents = 0;
67        int pr      = poll(&pfd, 1, poll_ms);
68        if (pr < 0) {
69            if (errno == EINTR) {
70                continue;
71            }
72            break;
73        }
74        if (pr == 0) {
75            continue; // timeout -> re-check running
76        }
77        if (pfd.revents & (POLLERR | POLLNVAL)) {
78            break;
79        }
80        ssize_t r = read(fd, chunk, sizeof(chunk));
81        if (r < 0) {
82            if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) {
83                continue;
84            }
85            break;
86        }
87        if (r == 0) {
88            break; // EOF: child (and any pipe writers) closed the stream
89        }
90        n = (size_t) r;
91#endif
92        buf.append(chunk, n);
93
94        // resync after an over-long, unterminated line: discard bytes until the next newline
95        if (skipping) {
96            size_t nl = buf.find('\n');
97            if (nl == std::string::npos) {
98                if (buf.size() > max_line) {
99                    buf.clear(); // stay bounded while waiting for a terminator
100                }
101                continue;
102            }
103            buf.erase(0, nl + 1);
104            skipping = false;
105        }
106
107        size_t pos;
108        while ((pos = buf.find('\n')) != std::string::npos) {
109            std::string line = buf.substr(0, pos);
110            buf.erase(0, pos + 1);
111            if (!line.empty() && line.back() == '\r') {
112                line.pop_back();
113            }
114            if (line.empty()) {
115                continue;
116            }
117            if (!on_line(std::move(line))) {
118                return;
119            }
120        }
121
122        // a partial line already larger than the cap and still no newline: drop it to avoid unbounded growth
123        if (buf.size() > max_line) {
124            SRV_WRN("MCP: dropping oversized line (> %zu bytes) from child pipe\n", max_line);
125            buf.clear();
126            skipping = true;
127        }
128    }
129}
130
131//
132// server_mcp_server_config
133//
134
135std::vector<server_mcp_server_config> server_mcp_server_config::parse_from_json(const std::string & json_str) {
136    return parse_cursor_format(json::parse(json_str));
137}
138
139std::vector<server_mcp_server_config> server_mcp_server_config::parse_cursor_format(const json & j) {
140    std::vector<server_mcp_server_config> result;
141
142    if (!j.contains("mcpServers") || !j.at("mcpServers").is_object()) {
143        return result;
144    }
145
146    for (const auto & [name, cfg] : j.at("mcpServers").items()) {
147        server_mcp_server_config sc;
148        sc.name = name;
149        sc.command = cfg.value("command", std::string());
150        sc.cwd = cfg.value("cwd", std::string());
151        sc.timeout_ms = cfg.value("timeout_ms", sc.timeout_ms);
152
153        if (cfg.contains("args") && cfg.at("args").is_array()) {
154            for (const auto & a : cfg.at("args")) {
155                sc.args.push_back(a.get<std::string>());
156            }
157        }
158        if (cfg.contains("env") && cfg.at("env").is_object()) {
159            for (const auto & [k, v] : cfg.at("env").items()) {
160                sc.env[k] = v.get<std::string>();
161            }
162        }
163
164        if (sc.command.empty()) {
165            SRV_WRN("MCP server '%s' has no command, skipping\n", name.c_str());
166            continue;
167        }
168        result.push_back(std::move(sc));
169    }
170
171    return result;
172}
173
174
175//
176// server_mcp_transport
177//
178
179static constexpr const char * MCP_PROTOCOL_VERSION = "2024-11-05";
180
181static std::string rpc_error_message(const json & resp) {
182    if (resp.contains("error")) {
183        const json & e = resp.at("error");
184        if (e.is_object()) {
185            return e.value("message", "unknown error");
186        }
187        if (e.is_string()) {
188            return e.get<std::string>();
189        }
190    }
191    return "unknown error";
192}
193
194// normalize an MCP tools/call result to the /tools contract (see README-dev.md):
195// concat text parts of result.content[], and surface an isError result
196static json mcp_result_to_response(const json & result) {
197    std::string text;
198    if (result.contains("content") && result.at("content").is_array()) {
199        for (const auto & part : result.at("content")) {
200            if (part.is_object() && part.value("type", "") == "text") {
201                if (!text.empty()) {
202                    text += "\n";
203                }
204                text += part.value("text", "");
205            }
206        }
207    }
208    if (result.is_object() && result.value("isError", false)) {
209        return {{"error", text.empty() ? "MCP tool returned an error" : text}};
210    }
211    return {{"plain_text_response", text}};
212}
213
214json server_mcp_transport::send_rpc(const json & request, const std::function<bool()> & should_stop) {
215    if (!to_server.write(request.dump())) {
216        return {{"error", {{"code", -32603}, {"message", "transport closed"}}}};
217    }
218
219    const bool has_id = request.contains("id");
220    const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
221    auto stop = [&]() {
222        return (should_stop && should_stop()) || std::chrono::steady_clock::now() >= deadline;
223    };
224
225    std::string frame;
226    while (from_server.read(frame, stop, false)) {
227        json reply;
228        try {
229            reply = json::parse(frame);
230        } catch (...) {
231            if (std::chrono::steady_clock::now() >= deadline) {
232                break;
233            }
234            continue; // skip malformed frame
235        }
236        // no id: a notification. mismatched id: a stale reply from a timed-out request (ids are monotonic, never a future one)
237        if (!has_id || (reply.contains("id") && reply.at("id") == request.at("id"))) {
238            return reply;
239        }
240        if (std::chrono::steady_clock::now() >= deadline) {
241            break; // a flood of notifications must not outrun the deadline
242        }
243    }
244
245    if (should_stop && should_stop()) {
246        return {{"error", {{"code", -32603}, {"message", "cancelled"}}}};
247    }
248    if (std::chrono::steady_clock::now() >= deadline) {
249        return {{"error", {{"code", -32603}, {"message", "request timed out"}}}};
250    }
251    return {{"error", {{"code", -32603}, {"message", "transport closed"}}}};
252}
253
254bool server_mcp_transport::ensure_init(const std::function<bool()> & should_stop) {
255    if (initialized) {
256        return true;
257    }
258
259    json init_req = {
260        {"jsonrpc", "2.0"},
261        {"id", next_id++},
262        {"method", "initialize"},
263        {"params", {
264            {"protocolVersion", MCP_PROTOCOL_VERSION},
265            {"capabilities", json::object()},
266            {"clientInfo", {{"name", "llama.cpp"}, {"version", "1.0"}}},
267        }},
268    };
269    json resp = send_rpc(init_req, should_stop);
270    if (!resp.contains("result")) {
271        last_error = "initialize failed: " + rpc_error_message(resp);
272        return false;
273    }
274
275    // notifications/initialized: no id, no reply expected
276    json notif = {{"jsonrpc", "2.0"}, {"method", "notifications/initialized"}};
277    to_server.write(notif.dump());
278
279    initialized = true;
280    return true;
281}
282
283std::vector<server_mcp_tool_def> server_mcp_transport::list_tools(const std::function<bool()> & should_stop) {
284    std::lock_guard<std::mutex> lock(rpc_mutex);
285    if (!ensure_init(should_stop)) {
286        return {};
287    }
288    if (!tools.empty()) {
289        return tools;
290    }
291
292    json req = {{"jsonrpc", "2.0"}, {"id", next_id++}, {"method", "tools/list"}};
293    json resp = send_rpc(req, should_stop);
294    if (!resp.contains("result")) {
295        last_error = "tools/list failed: " + rpc_error_message(resp);
296        return {};
297    }
298
299    const json & result = resp.at("result");
300    if (result.contains("tools") && result.at("tools").is_array()) {
301        for (const auto & t : result.at("tools")) {
302            server_mcp_tool_def def;
303            def.server_name = name;
304            def.name = t.value("name", "");
305            def.description = t.value("description", "");
306            if (t.contains("inputSchema")) {
307                def.input_schema = t.at("inputSchema");
308            }
309            tools.push_back(std::move(def));
310        }
311    }
312    return tools;
313}
314
315json server_mcp_transport::call_tool(const std::string & tool_name,
316                                     const json & arguments,
317                                     const std::function<bool()> & should_stop) {
318    std::lock_guard<std::mutex> lock(rpc_mutex);
319    if (!ensure_init(should_stop)) {
320        return {{"error", last_error}};
321    }
322
323    json req = {
324        {"jsonrpc", "2.0"},
325        {"id", next_id++},
326        {"method", "tools/call"},
327        {"params", {{"name", tool_name}, {"arguments", arguments}}},
328    };
329    json resp = send_rpc(req, should_stop);
330    if (resp.contains("error")) {
331        return {{"error", rpc_error_message(resp)}};
332    }
333    if (resp.contains("result")) {
334        return mcp_result_to_response(resp.at("result"));
335    }
336    return {{"error", "invalid response from MCP server"}};
337}
338
339//
340// server_mcp_stdio
341//
342
343struct server_mcp_stdio::process_handle {
344    common_subproc sp;
345    FILE * in  = nullptr; // child stdin
346    FILE * out = nullptr; // child stdout
347    FILE * err = nullptr; // child stderr
348};
349
350#if defined(_WIN32)
351// config strings are UTF-8 (from JSON) and subprocess.h converts them with CP_UTF8, so inputs must be UTF-8, not the active code page
352static std::wstring windows_utf8_to_wide(const std::string & s) {
353    if (s.empty()) {
354        return std::wstring();
355    }
356    int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int) s.size(), NULL, 0);
357    if (n <= 0) {
358        return std::wstring();
359    }
360    std::wstring w((size_t) n, L'\0');
361    MultiByteToWideChar(CP_UTF8, 0, s.data(), (int) s.size(), &w[0], n);
362    return w;
363}
364
365static std::string windows_wide_to_utf8(const wchar_t * s, int len /* -1 for NUL-terminated */) {
366    int n = WideCharToMultiByte(CP_UTF8, 0, s, len, NULL, 0, NULL, NULL);
367    if (n <= 0) {
368        return std::string();
369    }
370    std::string out((size_t) n, '\0');
371    WideCharToMultiByte(CP_UTF8, 0, s, len, &out[0], n, NULL, NULL);
372    if (len == -1 && !out.empty() && out.back() == '\0') {
373        out.pop_back(); // drop the terminator WideCharToMultiByte counts for -1
374    }
375    return out;
376}
377#endif
378
379static std::string mcp_resolve_command(const std::string & command) {
380#if defined(_WIN32)
381    // For Windows: make sure we handle ".exe" correctly, as well as UTF-8
382    std::wstring wcmd = windows_utf8_to_wide(command);
383    wchar_t      buf[MAX_PATH * 4];
384    const DWORD  cap = (DWORD) (sizeof(buf) / sizeof(buf[0]));
385
386    auto search = [&](const wchar_t * ext) -> std::string {
387        DWORD n = SearchPathW(NULL, wcmd.c_str(), ext, cap, buf, NULL);
388        return (n > 0 && n < cap) ? windows_wide_to_utf8(buf, (int) n) : std::string();
389    };
390
391    std::string found = search(NULL); // exact path / already-extensioned / .exe on PATH
392    if (!found.empty()) {
393        return found;
394    }
395
396    std::wstring pathext;
397    DWORD        need = GetEnvironmentVariableW(L"PATHEXT", NULL, 0);
398    if (need > 0) {
399        pathext.resize(need);
400        DWORD got = GetEnvironmentVariableW(L"PATHEXT", &pathext[0], need);
401        pathext.resize(got);
402    }
403    if (pathext.empty()) {
404        pathext = L".COM;.EXE;.BAT;.CMD";
405    }
406    for (size_t start = 0; start <= pathext.size();) {
407        size_t       sep = pathext.find(L';', start);
408        std::wstring ext = pathext.substr(start, sep == std::wstring::npos ? std::wstring::npos : sep - start);
409        if (!ext.empty()) {
410            found = search(ext.c_str());
411            if (!found.empty()) {
412                return found;
413            }
414        }
415        if (sep == std::wstring::npos) {
416            break;
417        }
418        start = sep + 1;
419    }
420    return command; // give up and let subprocess.h report the spawn error
421#else
422    return command;
423#endif // _WIN32
424}
425
426static std::vector<std::string> mcp_parent_env() {
427    std::vector<std::string> env;
428#if defined(_WIN32)
429    LPWCH block = GetEnvironmentStringsW();
430    if (block) {
431        for (LPWCH e = block; *e; e += wcslen(e) + 1) {
432            env.emplace_back(windows_wide_to_utf8(e, -1));
433        }
434        FreeEnvironmentStringsW(block);
435    }
436#else
437    if (environ) {
438        for (char ** e = environ; *e; ++e) {
439            env.emplace_back(*e);
440        }
441    }
442#endif
443    return env;
444}
445
446// parent env with the config overrides applied, in "KEY=VALUE" form
447static std::vector<std::string> mcp_build_env(const std::map<std::string, std::string> & overrides) {
448    std::vector<std::string> env;
449    for (auto & e : mcp_parent_env()) {
450        size_t eq = e.find('=');
451        std::string key = eq == std::string::npos ? e : e.substr(0, eq);
452        if (overrides.find(key) == overrides.end()) {
453            env.push_back(e);
454        }
455    }
456    for (auto & [k, v] : overrides) {
457        env.push_back(k + "=" + v);
458    }
459    return env;
460}
461
462server_mcp_stdio::server_mcp_stdio(const server_mcp_server_config & config) : config(config) {
463    name = config.name;
464    timeout_ms = config.timeout_ms;
465    // bound the reply queue: send_rpc only drains during a call, so unsolicited notifications would otherwise grow it without limit
466    from_server.max_size = 65536;
467}
468
469server_mcp_stdio::~server_mcp_stdio() {
470    join_pumps();
471}
472
473bool server_mcp_stdio::start() {
474    std::vector<std::string> argv_s;
475    argv_s.push_back(mcp_resolve_command(config.command));
476    argv_s.insert(argv_s.end(), config.args.begin(), config.args.end());
477
478    int options = subprocess_option_no_window | subprocess_option_search_user_path;
479    std::vector<std::string> envp_s;
480    if (config.env.empty()) {
481        options |= subprocess_option_inherit_environment;
482    } else {
483        envp_s = mcp_build_env(config.env);
484    }
485
486    auto handle = std::make_unique<process_handle>();
487    bool ok = handle->sp.create(argv_s, options, envp_s, config.cwd.empty() ? nullptr : config.cwd.c_str());
488    if (!ok) {
489        SRV_WRN("MCP '%s': failed to spawn '%s'\n", config.name.c_str(), config.command.c_str());
490        return false;
491    }
492    handle->in  = handle->sp.stdin_file();
493    handle->out = handle->sp.stdout_file();
494    handle->err = handle->sp.stderr_file();
495
496    proc = std::move(handle);
497    running.store(true);
498    reader = std::thread([this] { reader_loop(); });
499    writer = std::thread([this] { writer_loop(); });
500    errlog = std::thread([this] { errlog_loop(); });
501    return true;
502}
503
504void server_mcp_stdio::close() {
505    join_pumps();
506}
507
508bool server_mcp_stdio::is_alive() const {
509    return running.load();
510}
511
512std::string server_mcp_stdio::diagnostics() {
513    std::string out;
514    {
515        std::lock_guard<std::mutex> lock(rpc_mutex); // last_error is written by send_rpc's callers
516        out = last_error;
517    }
518    std::lock_guard<std::mutex> lk(err_mu);
519    if (!err_tail.empty()) {
520        if (!out.empty()) {
521            out += "; ";
522        }
523        out += "last stderr: " + err_tail;
524    }
525    return out;
526}
527
528void server_mcp_stdio::reader_loop() {
529    mcp_pump_ndjson(proc->out, running, [this](std::string && line) {
530        return from_server.write(std::move(line)); // false => consumer gone, stop
531    });
532    running.store(false);
533    to_server.close_write();   // stop the writer
534    from_server.close_write(); // EOF to any waiting caller
535}
536
537// write all of `data` to child stdin, non-blocking and polled so teardown never hangs (a grandchild can hold the read end of a full pipe open). returns false on error/close/shutdown.
538static bool mcp_write_all(FILE * f, const std::string & data, std::atomic<bool> & running) {
539    if (!f) {
540        return false;
541    }
542    size_t total = 0;
543#if defined(_WIN32)
544    HANDLE h      = (HANDLE) _get_osfhandle(_fileno(f));
545    DWORD  nowait = PIPE_NOWAIT;
546    SetNamedPipeHandleState(h, &nowait, NULL, NULL);
547    while (total < data.size() && running.load()) {
548        DWORD written = 0;
549        BOOL  ok      = WriteFile(h, data.data() + total, (DWORD) (data.size() - total), &written, NULL);
550        if (ok && written > 0) {
551            total += written;
552            continue;
553        }
554        if (!ok) {
555            DWORD err = GetLastError();
556            if (err != ERROR_NO_DATA && err != ERROR_PIPE_BUSY) {
557                return false;
558            }
559        }
560        // backpressure (pipe full) is rare for small JSON-RPC frames; sleep rather than spin.
561        // no writable-wait exists for a PIPE_NOWAIT anonymous pipe, so this polls like the POSIX poll() path.
562        std::this_thread::sleep_for(std::chrono::milliseconds(10));
563    }
564#else
565    int fd = fileno(f);
566    int fl = fcntl(fd, F_GETFL, 0);
567    if (fl >= 0) {
568        fcntl(fd, F_SETFL, fl | O_NONBLOCK);
569    }
570    while (total < data.size() && running.load()) {
571        ssize_t n = write(fd, data.data() + total, data.size() - total);
572        if (n > 0) {
573            total += (size_t) n;
574            continue;
575        }
576        if (n == 0) {
577            return false;
578        }
579        if (errno == EINTR) {
580            continue;
581        }
582        if (errno != EAGAIN && errno != EWOULDBLOCK) {
583            return false;
584        }
585        struct pollfd pfd;
586        pfd.fd      = fd;
587        pfd.events  = POLLOUT;
588        pfd.revents = 0;
589        int pr      = poll(&pfd, 1, 50);
590        if (pr < 0) {
591            if (errno == EINTR) {
592                continue;
593            }
594            return false;
595        }
596        if (pfd.revents & (POLLERR | POLLNVAL | POLLHUP)) {
597            return false;
598        }
599    }
600#endif
601    return total == data.size();
602}
603
604void server_mcp_stdio::writer_loop() {
605    auto should_stop = [this] { return !running.load(); };
606    std::string msg;
607    while (to_server.read(msg, should_stop)) {
608        msg.push_back('\n');
609        if (!mcp_write_all(proc->in, msg, running)) {
610            break; // child gone or shutting down
611        }
612    }
613    running.store(false);
614    to_server.close_read();    // fail fast on any further send_rpc write
615    from_server.close_write(); // wake any caller waiting for a reply
616}
617
618void server_mcp_stdio::errlog_loop() {
619    static constexpr size_t ERR_TAIL_MAX = 4096;
620    // drain stderr (an undrained pipe blocks the child):
621    // log it, and keep a bounded tail for reporting when the server dies
622    mcp_pump_ndjson(proc->err, running, [this](std::string && line) {
623        SRV_DBG("MCP '%s' stderr: %s\n", name.c_str(), line.c_str());
624        std::lock_guard<std::mutex> lk(err_mu);
625        err_tail += line;
626        err_tail += '\n';
627        if (err_tail.size() > ERR_TAIL_MAX) {
628            err_tail.erase(0, err_tail.size() - ERR_TAIL_MAX);
629        }
630        return true;
631    });
632}
633
634void server_mcp_stdio::join_pumps() {
635    if (!proc) {
636        return;
637    }
638    running.store(false);
639    to_server.close_write();   // wake the writer if it waits for a message
640    from_server.close_write(); // wake any caller waiting for a reply
641
642    proc->sp.terminate(); // child death unblocks the blocked fread/fwrite
643
644    if (writer.joinable()) writer.join();
645    if (reader.joinable()) reader.join();
646    if (errlog.joinable()) errlog.join();
647
648    proc->sp.join(); // reap the child: never waiting would leave the pid a zombie for the process lifetime
649    proc.reset();
650}
651
652
653//
654// server_mcp
655//
656
657static constexpr int MCP_COOLDOWN_SECONDS = 5;
658static constexpr int MCP_WARMUP_TIMEOUT_SECONDS = 10; // cap per-server tool discovery at startup
659
660server_mcp::~server_mcp() {
661    shutdown();
662
663    std::vector<std::shared_ptr<server_mcp_transport>> to_close;
664    {
665        std::lock_guard<std::mutex> lock(mutex);
666        for (auto & [name, t] : transports) {
667            to_close.push_back(std::move(t));
668        }
669        transports.clear();
670    }
671    for (auto & t : to_close) {
672        t->close();
673    }
674}
675
676std::shared_ptr<server_mcp_transport> server_mcp::create_transport(const server_mcp_server_config & cfg) {
677    return std::make_shared<server_mcp_stdio>(cfg);
678}
679
680void server_mcp::shutdown() {
681    stopping.store(true);
682}
683
684const server_mcp_server_config * server_mcp::find_config(const std::string & name) const {
685    for (const auto & c : configs) {
686        if (c.name == name) {
687            return &c;
688        }
689    }
690    return nullptr;
691}
692
693void server_mcp::start(const common_params & params) {
694    auto append = [this](const std::string & json_str) {
695        try {
696            auto parsed = server_mcp_server_config::parse_from_json(json_str);
697            if (parsed.empty()) {
698                SRV_WRN("%s", "MCP config: no servers found in JSON\n");
699            }
700            for (auto & p : parsed) {
701                // names must be unique across both config sources: get_or_create / find_config key on the name
702                if (find_config(p.name)) {
703                    SRV_WRN("MCP config: duplicate server name '%s', skipping\n", p.name.c_str());
704                    continue;
705                }
706                configs.push_back(std::move(p));
707            }
708        } catch (const std::exception & e) {
709            throw std::runtime_error(std::string("failed to parse MCP config JSON: ") + e.what());
710        }
711    };
712    if (!params.mcp_servers_config.empty()) {
713        std::ifstream f = fs_open_ifstream(params.mcp_servers_config, std::ios::in);
714        if (!f) {
715            throw std::runtime_error("failed to open MCP config file: " + params.mcp_servers_config);
716        }
717        std::stringstream ss;
718        ss << f.rdbuf();
719        append(ss.str());
720    }
721    if (!params.mcp_servers_json.empty()) {
722        append(params.mcp_servers_json);
723    }
724
725    if (configs.empty()) {
726        return;
727    }
728
729    std::vector<server_mcp_tool_def> discovered;
730    for (const auto & cfg : configs) {
731        auto t = create_transport(cfg);
732        if (!t->start()) {
733            SRV_WRN("MCP warmup: failed to spawn '%s': %s\n", cfg.name.c_str(), t->diagnostics().c_str());
734            continue;
735        }
736        // bound warmup per server so an unresponsive one can't stall startup for the full per-call timeout
737        const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(MCP_WARMUP_TIMEOUT_SECONDS);
738        auto should_stop = [this, deadline]() {
739            return stopping.load() || std::chrono::steady_clock::now() >= deadline;
740        };
741        auto tools = t->list_tools(should_stop);
742        SRV_INF("MCP warmup: '%s' discovered %zu tools\n", cfg.name.c_str(), tools.size());
743        discovered.insert(discovered.end(), tools.begin(), tools.end());
744        t->close();
745    }
746
747    std::lock_guard<std::mutex> lock(mutex);
748    registry.swap(discovered);
749}
750
751std::vector<server_mcp_tool_def> server_mcp::list_tools() const {
752    std::lock_guard<std::mutex> lock(mutex);
753    return registry;
754}
755
756json server_mcp::call_tool(const std::string & server_name,
757                           const std::string & tool_name,
758                           const json & arguments,
759                           const std::function<bool()> & should_stop) {
760    auto transport = get_or_create(server_name);
761    if (!transport) {
762        return {{"error", "MCP server unavailable: " + server_name}};
763    }
764
765    auto stop = [this, &should_stop]() {
766        return stopping.load() || (should_stop && should_stop());
767    };
768    return transport->call_tool(tool_name, arguments, stop);
769}
770
771std::shared_ptr<server_mcp_transport> server_mcp::get_or_create(const std::string & name) {
772    std::vector<std::shared_ptr<server_mcp_transport>> to_close; // closed after unlock
773    std::shared_ptr<server_mcp_transport> result;
774
775    {
776        std::lock_guard<std::mutex> lock(mutex);
777        if (stopping.load()) {
778            return nullptr;
779        }
780
781        auto now = std::chrono::steady_clock::now();
782        auto dead_it = dead_servers.find(name);
783        if (dead_it != dead_servers.end()) {
784            if (now < dead_it->second) {
785                return nullptr;
786            }
787            dead_servers.erase(dead_it);
788        }
789
790        auto it = transports.find(name);
791        if (it != transports.end()) {
792            if (it->second->is_alive()) {
793                return it->second;
794            }
795            SRV_WRN("MCP '%s' is no longer alive: %s\n", name.c_str(), it->second->diagnostics().c_str());
796            to_close.push_back(std::move(it->second));
797            transports.erase(it);
798        }
799
800        const server_mcp_server_config * cfg = find_config(name);
801        if (cfg) {
802            auto fresh = create_transport(*cfg);
803            if (fresh->start() && fresh->is_alive()) {
804                transports[name] = fresh;
805                result = fresh;
806            } else {
807                SRV_WRN("MCP '%s': failed to start: %s\n", name.c_str(), fresh->diagnostics().c_str());
808                to_close.push_back(std::move(fresh));
809                dead_servers[name] = now + std::chrono::seconds(MCP_COOLDOWN_SECONDS);
810            }
811        }
812    }
813
814    for (auto & t : to_close) {
815        t->close(); // blocking call, no leaks
816    }
817
818    return result;
819}
820