Files
llama.cpp/tools/server/server-tools.cpp
Xuan-Son Nguyen 20455a4ad3 server: support MCP stdio (#26062)
* move server_pipe to common

* init impl

* vendor: update subprocess.h

* add server_mcp_stdio

* stderr drain

* server_mcp_transport

* server_mcp_stdio is now framing-only, no json

* internal/mcp-stdio: integration + tests + fixes (#26075)

* server-mcp: harden transport and wire up the tool integration

Builds on the transport/manager architecture (server_mcp_transport + server_pipe)
with the hardening and integration the draft did not yet have.

Hardening:
* Reader and stderr pumps are polled (running-aware) instead of blocking on a read
  that only ends at EOF. subprocess_terminate() SIGKILLs only the direct child, so a
  grandchild the MCP server spawned that inherited the pipe would otherwise keep the
  write end open and hang teardown (both warmup shutdown at startup and process
  shutdown). The writer is likewise non-blocking + polled.
* Windows: resolve the command through PATHEXT so "npx" (npm ships npx.cmd, never
  npx.exe) spawns, matching POSIX's PATH search; and enumerate the parent environment
  as UTF-8 (GetEnvironmentStringsW) instead of the active code page.
* server_pipe gains an opt-in max_size (default unbounded, so the router's streaming
  use is unchanged); the MCP reply queue uses it so a server that streams unsolicited
  notifications between requests cannot grow it without bound.

Integration:
* --mcp-servers-config / --mcp-servers-json flags; enabling MCP restricts default CORS
  to localhost, same as --tools.
* MCP tools are exposed through /tools (and chat-completions) as <server>_<tool>,
  skipping names that collide with a built-in or another MCP tool.
* Manager lifecycle wired into llama_server(): warmup at start, shutdown() from the
  signal handler before the HTTP server drains, blocking teardown in clean_up().
* SIGPIPE ignored so a child dying mid-write yields EPIPE rather than killing us.

Assisted-By: Claude Opus 4.8 <noreply@anthropic.com>

* server-mcp: add MCP test suite with grandchild deadlock regression test

21 tests over the /tools endpoint: tool discovery/invocation, timeouts, crash
recovery and respawn cooldown, warmup partial failure, malformed and batched
notification+response output, tool-definition shape, and prompt shutdown during a
slow call.

The last test spawns an MCP server that leaves a grandchild inheriting its
stdout/stderr and asserts the server both starts and stops promptly. Verified it
fails (5s SIGKILL fallback on a deadlocked reader-join) when the pump is made to
ignore the running flag, and passes with the polled reader.

Assisted-By: Claude Opus 4.8 <noreply@anthropic.com>

* clean up

* clean up 2

* even stricter life cycle

* nits

* nits 2

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>

* fix some edge cases

* fix last_error data race

* fix response schema + docs

* server: fix MCP zombie leak and timeout-induced transport teardown

join_pumps() never reaped the child, leaking one zombie per spawn:
call subprocess_join() before subprocess_destroy().

A per-call timeout permanently closed from_server and got a healthy
transport evicted: add close_on_stop to server_pipe::read() and pass
false from send_rpc(), where should_stop is a per-request deadline
and a late reply is already skipped on id mismatch.

Also drop the unreachable disconnect cancellation in
server_mcp_tool::invoke(): support_stream is false, st is always null.

(cherry picked from commit e6de1ec043174fd0570b1e60d47f06c7c19d620d)

Assisted-by: Claude Opus 4.8

* server: make MCP test fixtures JSON-RPC 2.0 compliant

Add the missing notification guard to mcp_malformed_server.py and
mcp_burst_server.py (the latter treated id 0 as a notification and
replied to unknown ones; its notification table is now unused).

Return -32602 instead of -32601 for unknown tools: tools/call is a
valid method, the tool name is the invalid parameter.

Also fix the test module docstring: tools are named <server>_<tool>.

(cherry picked from commit 74a08e8c311dabf3b49d06cc6d754b0097ae7a38)

Assisted-by: Claude Opus 4.8

---------

Co-authored-by: Piotr Wilkin (ilintar) <piotr.wilkin@syndatis.com>
Co-authored-by: Pascal <admin@serveurperso.com>
2026-07-26 01:08:49 +02:00

1340 lines
51 KiB
C++

#include "server-tools.h"
#include <sheredom/subprocess.h>
#include <filesystem>
#include <fstream>
#include <regex>
#include <thread>
#include <chrono>
#include <ctime>
#include <atomic>
#include <cstring>
#include <algorithm>
#include <unordered_set>
#include <functional>
#include <memory>
namespace fs = std::filesystem;
//
// internal helpers
//
json server_tool::to_json() const {
return {
{"display_name", display_name},
{"tool", name},
{"type", type()},
{"permissions", json{
{"write", permission_write}
}},
{"definition", get_definition()},
};
}
static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024; // 8 MB
static constexpr int SERVER_TOOL_GIT_LS_FILES_TIMEOUT = 15; // seconds
class tools_io {
public:
struct exec_result {
std::string output;
int exit_code = -1;
bool timed_out = false;
};
virtual ~tools_io() = default;
virtual bool is_directory(const std::string & path) const = 0;
virtual bool is_regular_file(const std::string & path) const = 0;
virtual bool file_size(const std::string & path, uintmax_t & out_size) const = 0;
virtual bool read_file(const std::string & path, std::string & out) const = 0;
virtual bool write_file(const std::string & path, const std::string & content) const = 0;
// paths relative to `base`, '/'-separated; sets `err` if `base` isn't a directory
virtual std::vector<std::string> list_files(const std::string & base, std::string & err) const = 0;
// on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in);
// returning false terminates the process early (e.g. the client disconnected)
virtual exec_result run(
const std::vector<std::string> & args,
size_t max_output,
int timeout_secs,
const std::function<bool(const std::string &)> & on_chunk = nullptr) const = 0;
};
class tools_io_basic : public tools_io {
public:
bool is_directory(const std::string & path) const override {
std::error_code ec;
return fs::is_directory(path, ec) && !ec;
}
bool is_regular_file(const std::string & path) const override {
std::error_code ec;
return fs::is_regular_file(path, ec) && !ec;
}
bool file_size(const std::string & path, uintmax_t & out_size) const override {
std::error_code ec;
out_size = fs::file_size(path, ec);
return !ec;
}
bool read_file(const std::string & path, std::string & out) const override {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::ostringstream ss;
ss << f.rdbuf();
out = ss.str();
return true;
}
bool write_file(const std::string & path, const std::string & content) const override {
std::error_code ec;
fs::path fpath(path);
if (fpath.has_parent_path()) {
fs::create_directories(fpath.parent_path(), ec);
if (ec) return false;
}
std::ofstream f(path, std::ios::binary);
if (!f) return false;
f << content;
return (bool) f;
}
std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
err.clear();
if (!is_directory(base)) {
err = "path does not exist or is not a directory: " + base;
return {};
}
auto res = run(
{"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"},
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT);
if (res.exit_code == 0 && !res.timed_out) {
std::vector<std::string> result;
std::istringstream iss(res.output);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
std::replace(line.begin(), line.end(), '\\', '/');
if (is_regular_file((fs::path(base) / line).string())) {
result.push_back(line);
}
}
return result;
}
return list_files_fallback(base);
}
exec_result run(
const std::vector<std::string> & args,
size_t max_output,
int timeout_secs,
const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
exec_result res;
subprocess_s proc;
auto argv = to_cstr_vec(args);
int options = subprocess_option_no_window
| subprocess_option_combined_stdout_stderr
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (subprocess_create(argv.data(), options, &proc) != 0) {
res.output = "failed to spawn process";
return res;
}
std::atomic<bool> done{false};
std::atomic<bool> timed_out{false};
std::thread timeout_thread([&]() {
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs);
while (!done.load()) {
if (std::chrono::steady_clock::now() >= deadline) {
timed_out.store(true);
subprocess_terminate(&proc);
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
FILE * f = subprocess_stdout(&proc);
std::string output;
bool truncated = false;
if (f) {
char buf[4096];
while (fgets(buf, sizeof(buf), f) != nullptr) {
if (!truncated) {
size_t len = strlen(buf);
if (output.size() + len <= max_output) {
output.append(buf, len);
if (on_chunk && !on_chunk(std::string(buf, len))) {
subprocess_terminate(&proc);
break;
}
} else {
size_t remaining = max_output - output.size();
output.append(buf, remaining);
if (on_chunk && remaining > 0) on_chunk(std::string(buf, remaining));
truncated = true;
}
}
}
}
done.store(true);
if (timeout_thread.joinable()) {
timeout_thread.join();
}
subprocess_join(&proc, &res.exit_code);
subprocess_destroy(&proc);
res.output = output;
res.timed_out = timed_out.load();
if (truncated) {
res.output += "\n[output truncated]";
}
return res;
}
private:
static std::vector<char *> to_cstr_vec(const std::vector<std::string> & v) {
std::vector<char *> r;
r.reserve(v.size() + 1);
for (const auto & s : v) {
r.push_back(const_cast<char *>(s.c_str()));
}
r.push_back(nullptr);
return r;
}
static const std::unordered_set<std::string> & junk_dir_names() {
static const std::unordered_set<std::string> names = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode",
};
return names;
}
std::vector<std::string> list_files_fallback(const std::string & base) const {
std::vector<std::string> result;
std::error_code ec;
std::vector<std::pair<fs::path, fs::path>> stack;
stack.emplace_back(fs::path(base), fs::path());
while (!stack.empty()) {
auto [dir, rel_dir] = stack.back();
stack.pop_back();
for (const auto & entry : fs::directory_iterator(dir, fs::directory_options::skip_permission_denied, ec)) {
if (ec) break;
std::string fname = entry.path().filename().string();
std::error_code tec;
if (entry.is_directory(tec)) {
if (junk_dir_names().count(fname) > 0) continue;
stack.emplace_back(entry.path(), rel_dir / fname);
} else if (entry.is_regular_file(tec)) {
std::string rel = (rel_dir / fname).string();
std::replace(rel.begin(), rel.end(), '\\', '/');
result.push_back(rel);
}
}
}
return result;
}
};
static std::unique_ptr<tools_io> make_tools_io(const json & params) {
GGML_UNUSED(params); // TODO in follow-up PR
return std::make_unique<tools_io_basic>();
}
// no '/' in pattern -> match basename at any depth; else match full relative path
static bool path_glob_match(const std::string & pattern, const std::string & rel_path) {
if (pattern.find('/') == std::string::npos) {
return glob_match(pattern, fs::path(rel_path).filename().string());
}
if (pattern == "**" || pattern.rfind("**/", 0) == 0 || pattern.rfind('/', 0) == 0) {
return glob_match(pattern, rel_path);
}
return glob_match("**/" + pattern, rel_path);
}
//
// read_file: read a file with optional line range and line-number prefix
//
static constexpr size_t SERVER_TOOL_READ_FILE_MAX_SIZE = 16 * 1024; // 16 KB
struct server_tool_read_file : server_tool {
server_tool_read_file() {
name = "read_file";
display_name = "Read file";
permission_write = false;
}
json get_definition() const override {
return {
{"type", "function"},
{"function", {
{"name", name},
{"description", "Read the contents of a file. Optionally specify a 1-based line range. "
"If append_loc is true, each line is prefixed with its line number (e.g. \"1\u2192...\")."},
{"parameters", {
{"type", "object"},
{"properties", {
{"path", {{"type", "string"}, {"description", "Path to the file"}}},
{"start_line", {{"type", "integer"}, {"description", "First line to read, 1-based (default: 1)"}}},
{"end_line", {{"type", "integer"}, {"description", "Last line to read, 1-based inclusive (default: end of file)"}}},
{"append_loc", {{"type", "boolean"}, {"description", "Prefix each line with its line number"}}},
}},
{"required", json::array({"path"})},
}},
}},
};
}
json invoke(json params, server_tool::stream *) const override {
std::string path = params.at("path").get<std::string>();
int start_line = json_value(params, "start_line", 1);
int end_line = json_value(params, "end_line", -1); // -1 = no limit
bool append_loc = json_value(params, "append_loc", false);
auto io = make_tools_io(params);
uintmax_t file_size = 0;
if (!io->file_size(path, file_size)) {
return {{"error", "cannot stat file: " + path}};
}
if (file_size > SERVER_TOOL_READ_FILE_MAX_SIZE && end_line == -1) {
return {{"error", string_format(
"file too large (%zu bytes, max %zu). Use start_line/end_line to read a portion.",
(size_t)file_size, SERVER_TOOL_READ_FILE_MAX_SIZE)}};
}
std::string content;
if (!io->read_file(path, content)) {
return {{"error", "failed to open file: " + path}};
}
std::istringstream f(content);
std::string result;
std::string line;
int lineno = 0;
while (std::getline(f, line)) {
lineno++;
if (lineno < start_line) continue;
if (end_line != -1 && lineno > end_line) break;
std::string out_line;
if (append_loc) {
out_line = std::to_string(lineno) + "\u2192" + line + "\n";
} else {
out_line = line + "\n";
}
if (result.size() + out_line.size() > SERVER_TOOL_READ_FILE_MAX_SIZE) {
result += "[output truncated]";
break;
}
result += out_line;
}
return {{"plain_text_response", result}};
}
};
//
// file_glob_search: find files matching a glob pattern under a base directory
//
static constexpr size_t SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100;
struct server_tool_file_glob_search : server_tool {
server_tool_file_glob_search() {
name = "file_glob_search";
display_name = "File search";
permission_write = false;
}
json get_definition() const override {
return {
{"type", "function"},
{"function", {
{"name", name},
{"description",
"Recursively search for files matching a glob pattern under a directory. "
"Automatically skips files ignored by .gitignore (when the directory is inside a git repo) "
"and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
"A pattern with no '/' (e.g. \"*.cpp\") matches the file's basename at any depth. "
"A pattern containing '/' matches the full relative path; unless already anchored with "
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\"."},
{"parameters", {
{"type", "object"},
{"properties", {
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
}},
{"required", json::array({"path"})},
}},
}},
};
}
json invoke(json params, server_tool::stream *) const override {
std::string base = params.at("path").get<std::string>();
std::string include = json_value(params, "include", std::string("**"));
std::string exclude = json_value(params, "exclude", std::string(""));
auto io = make_tools_io(params);
std::string err;
auto files = io->list_files(base, err);
if (!err.empty()) {
return {{"error", err}};
}
std::vector<std::string> matches;
for (const auto & rel : files) {
if (!path_glob_match(include, rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
matches.push_back(rel);
}
size_t total = matches.size();
size_t shown = std::min(total, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
std::ostringstream output_text;
for (size_t i = 0; i < shown; i++) {
output_text << matches[i] << "\n";
}
output_text << "\n---\nTotal matches: " << total << "\n";
if (total > shown) {
output_text << string_format(
"[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n",
shown, total);
}
return {{"plain_text_response", output_text.str()}};
}
};
//
// grep_search: search for a regex pattern in files
//
static constexpr size_t SERVER_TOOL_GREP_SEARCH_MAX_RESULTS = 100;
struct server_tool_grep_search : server_tool {
server_tool_grep_search() {
name = "grep_search";
display_name = "Grep search";
permission_write = false;
}
json get_definition() const override {
return {
{"type", "function"},
{"function", {
{"name", name},
{"description",
"Search for a pattern in files under a path. Returns matching lines with file paths "
"(and, unless searching a single file, paths relative to the given directory). "
"Automatically skips files ignored by .gitignore (when the directory is inside a git repo) "
"and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
"include/exclude: a pattern with no '/' matches the basename at any depth; a pattern "
"containing '/' matches the full relative path (auto-anchored with \"**/\" unless already anchored)."},
{"parameters", {
{"type", "object"},
{"properties", {
{"path", {{"type", "string"}, {"description", "File or directory to search in"}}},
{"pattern", {{"type", "string"}, {"description", "Pattern to search for (regular expression unless literal is true)"}}},
{"include", {{"type", "string"}, {"description", "Glob pattern to filter files (default: **)"}}},
{"exclude", {{"type", "string"}, {"description", "Glob pattern to exclude files"}}},
{"return_line_numbers", {{"type", "boolean"}, {"description", "If true, include line numbers in results"}}},
{"literal", {{"type", "boolean"}, {"description", "Treat pattern as a literal string instead of a regular expression (default: false)"}}},
{"ignore_case", {{"type", "boolean"}, {"description", "Case-insensitive search (default: false)"}}},
{"context_lines", {{"type", "integer"}, {"description", "Number of lines of context to show before and after each match (default: 0)"}}},
}},
{"required", json::array({"path", "pattern"})},
}},
}},
};
}
json invoke(json params, server_tool::stream *) const override {
std::string path = params.at("path").get<std::string>();
std::string pat_str = params.at("pattern").get<std::string>();
std::string include = json_value(params, "include", std::string("**"));
std::string exclude = json_value(params, "exclude", std::string(""));
bool show_lineno = json_value(params, "return_line_numbers", false);
bool literal = json_value(params, "literal", false);
bool ignore_case = json_value(params, "ignore_case", false);
int ctx_lines = std::max(0, json_value(params, "context_lines", 0));
std::string pattern_src = pat_str;
if (literal) {
static const std::string specials = "\\^$.|?*+()[]{}";
std::string escaped;
escaped.reserve(pat_str.size() * 2);
for (char c : pat_str) {
if (specials.find(c) != std::string::npos) escaped += '\\';
escaped += c;
}
pattern_src = escaped;
}
std::regex pattern;
try {
auto flags = std::regex::ECMAScript;
if (ignore_case) flags |= std::regex::icase;
pattern = std::regex(pattern_src, flags);
} catch (const std::regex_error & e) {
return {{"error", std::string("invalid regex: ") + e.what()}};
}
auto io = make_tools_io(params);
// collect (absolute_path, display_path) pairs to search
std::vector<std::pair<std::string, std::string>> files;
if (io->is_regular_file(path)) {
files.emplace_back(path, path);
} else if (io->is_directory(path)) {
std::string err;
auto candidates = io->list_files(path, err);
if (!err.empty()) {
return {{"error", err}};
}
for (const auto & rel : candidates) {
if (!path_glob_match(include, rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
files.emplace_back((fs::path(path) / rel).string(), rel);
}
} else {
return {{"error", "path does not exist: " + path}};
}
std::ostringstream output_text;
size_t total = 0;
bool limit_reached = false;
bool show_num = show_lineno || ctx_lines > 0;
for (const auto & file_entry : files) {
if (limit_reached) break;
const std::string & fpath = file_entry.first;
const std::string & display_path = file_entry.second;
std::string content;
if (!io->read_file(fpath, content)) continue;
std::vector<std::string> lines;
{
std::istringstream f(content);
std::string line;
while (std::getline(f, line)) lines.push_back(line);
}
for (size_t i = 0; i < lines.size(); i++) {
if (total >= SERVER_TOOL_GREP_SEARCH_MAX_RESULTS) {
limit_reached = true;
break;
}
if (!std::regex_search(lines[i], pattern)) continue;
long ctx_start = ctx_lines > 0 ? std::max<long>(0, (long) i - ctx_lines) : (long) i;
long ctx_end = ctx_lines > 0 ? std::min<long>((long) lines.size() - 1, (long) i + ctx_lines) : (long) i;
for (long j = ctx_start; j <= ctx_end; j++) {
bool is_match = (j == (long) i);
output_text << display_path << (is_match ? ':' : '-');
if (show_num) {
output_text << (j + 1) << (is_match ? ':' : '-');
}
output_text << lines[j] << "\n";
}
if (ctx_lines > 0) {
output_text << "--\n";
}
total++;
}
}
output_text << "\n---\nTotal matches: " << total << "\n";
if (limit_reached) {
output_text << string_format(
"[%zu matches limit reached. Narrow the path/pattern/include to see more.]\n",
SERVER_TOOL_GREP_SEARCH_MAX_RESULTS);
}
return {{"plain_text_response", output_text.str()}};
}
};
//
// exec_shell_command: run an arbitrary shell command
//
static constexpr size_t SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE = 16 * 1024; // 16 KB
static constexpr int SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT = 60; // seconds
struct server_tool_exec_shell_command : server_tool {
server_tool_exec_shell_command() {
name = "exec_shell_command";
display_name = "Execute shell command";
permission_write = true;
support_stream = true;
}
json get_definition() const override {
return {
{"type", "function"},
{"function", {
{"name", name},
{"description", "Execute a shell command and return its output (stdout and stderr combined)."},
{"parameters", {
{"type", "object"},
{"properties", {
{"command", {{"type", "string"}, {"description", "Shell command to execute"}}},
{"timeout", {{"type", "integer"}, {"description", string_format("Timeout in seconds (default 10, max %d)", SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT)}}},
{"max_output_size", {{"type", "integer"}, {"description", string_format("Maximum output size in bytes (default %zu)", SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE)}}},
}},
{"required", json::array({"command"})},
}},
}},
};
}
json invoke(json params, server_tool::stream * st) const override {
std::string command = params.at("command").get<std::string>();
int timeout = json_value(params, "timeout", 10);
size_t max_output = (size_t) json_value(params, "max_output_size", (int) SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE);
timeout = std::min(timeout, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT);
max_output = std::min(max_output, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE);
#ifdef _WIN32
std::vector<std::string> args = {"cmd", "/c", command};
#else
std::vector<std::string> args = {"sh", "-c", command};
#endif
auto io = make_tools_io(params);
if (st) {
auto res = io->run(args, max_output, timeout, [st](const std::string & chunk) {
st->push(chunk);
return !st->alive || st->alive();
});
if (st->alive && !st->alive()) {
return json();
}
std::string tail = string_format("\n[exit code: %d]", res.exit_code);
if (res.timed_out) {
tail += " [exit due to timed out]";
}
st->push(tail);
return json();
}
auto res = io->run(args, max_output, timeout);
std::string text_output = res.output;
text_output += string_format("\n[exit code: %d]", res.exit_code);
if (res.timed_out) {
text_output += " [exit due to timed out]";
}
return {{"plain_text_response", text_output}};
}
};
//
// write_file: create or overwrite a file
//
struct server_tool_write_file : server_tool {
server_tool_write_file() {
name = "write_file";
display_name = "Write file";
permission_write = true;
}
json get_definition() const override {
return {
{"type", "function"},
{"function", {
{"name", name},
{"description", "Write content to a file, creating it (including parent directories) if it does not exist. May use with edit_file for more complex edits."},
{"parameters", {
{"type", "object"},
{"properties", {
{"path", {{"type", "string"}, {"description", "Path of the file to write"}}},
{"content", {{"type", "string"}, {"description", "Content to write"}}},
}},
{"required", json::array({"path", "content"})},
}},
}},
};
}
json invoke(json params, server_tool::stream *) const override {
std::string path = params.at("path").get<std::string>();
std::string content = params.at("content").get<std::string>();
auto io = make_tools_io(params);
if (!io->write_file(path, content)) {
return {{"error", "failed to write file: " + path}};
}
return {{"result", "file written successfully"}, {"path", path}, {"bytes", content.size()}};
}
};
//
// edit_file: exact text replacement, one or more edits per call
//
struct server_tool_edit_file : server_tool {
server_tool_edit_file() {
name = "edit_file";
display_name = "Edit file";
permission_write = true;
}
json get_definition() const override {
return {
{"type", "function"},
{"function", {
{"name", name},
{"description",
"Edit a file using exact text replacement. Each edits[].old_text must be unique in the file "
"and is matched against the original content, not incrementally. Merge nearby changes into "
"one edit instead of overlapping edits. Use write_file to replace the whole file."},
{"parameters", {
{"type", "object"},
{"properties", {
{"path", {{"type", "string"}, {"description", "Path to the file to edit"}}},
{"edits", {
{"type", "array"},
{"description", "One or more exact text replacements to apply"},
{"items", {
{"type", "object"},
{"properties", {
{"old_text", {{"type", "string"}, {"description", "Exact text to find; must be unique in the file and must not overlap with other edits"}}},
{"new_text", {{"type", "string"}, {"description", "Text to replace old_text with"}}},
}},
{"required", json::array({"old_text", "new_text"})},
}},
}},
}},
{"required", json::array({"path", "edits"})},
}},
}},
};
}
json invoke(json params, server_tool::stream *) const override {
std::string path = params.at("path").get<std::string>();
const json & edits_json = params.at("edits");
if (!edits_json.is_array() || edits_json.empty()) {
return {{"error", "\"edits\" must be a non-empty array"}};
}
struct edit_req {
std::string old_text;
std::string new_text;
};
std::vector<edit_req> edits;
edits.reserve(edits_json.size());
for (const auto & e : edits_json) {
edit_req er;
er.old_text = e.at("old_text").get<std::string>();
er.new_text = e.at("new_text").get<std::string>();
if (er.old_text.empty()) {
return {{"error", string_format("edits[%zu].old_text must not be empty", edits.size())}};
}
edits.push_back(std::move(er));
}
auto io = make_tools_io(params);
std::string original_content;
if (!io->read_file(path, original_content)) {
return {{"error", "failed to open file: " + path}};
}
// does any old_text need fuzzy matching (no exact match found)?
bool any_fuzzy = false;
for (size_t i = 0; i < edits.size(); i++) {
if (original_content.find(edits[i].old_text) != std::string::npos) continue;
std::string fuzzy_content = normalize_for_fuzzy_match(original_content);
std::string fuzzy_old = normalize_for_fuzzy_match(edits[i].old_text);
if (fuzzy_content.find(fuzzy_old) == std::string::npos) {
return {{"error", string_format(
"could not find edits[%zu].old_text in %s, it must match the file's current content exactly",
i, path.c_str())}};
}
any_fuzzy = true;
}
std::string base_content = any_fuzzy ? normalize_for_fuzzy_match(original_content) : original_content;
// uniqueness check always uses fuzzy-normalized text, so a whitespace-only duplicate still counts
std::vector<matched_edit> matched;
matched.reserve(edits.size());
for (size_t i = 0; i < edits.size(); i++) {
std::string needle = any_fuzzy ? normalize_for_fuzzy_match(edits[i].old_text) : edits[i].old_text;
size_t occurrences = count_occurrences(
normalize_for_fuzzy_match(original_content),
normalize_for_fuzzy_match(edits[i].old_text));
if (occurrences > 1) {
return {{"error", string_format(
"found %zu occurrences of edits[%zu].old_text in %s, it must be unique",
occurrences, i, path.c_str())}};
}
size_t idx = base_content.find(needle);
matched.push_back({i, idx, needle.size(), edits[i].new_text});
}
std::sort(matched.begin(), matched.end(), [](const matched_edit & a, const matched_edit & b) {
return a.match_index < b.match_index;
});
for (size_t i = 1; i < matched.size(); i++) {
if (matched[i - 1].match_index + matched[i - 1].match_length > matched[i].match_index) {
return {{"error", string_format(
"edits[%zu] and edits[%zu] overlap in %s; merge them into one edit or target disjoint regions",
matched[i - 1].edit_index, matched[i].edit_index, path.c_str())}};
}
}
std::string new_content = any_fuzzy
? apply_replacements_preserving_unchanged_lines(original_content, base_content, matched)
: apply_replacements(base_content, matched, 0);
if (new_content == original_content) {
return {{"error", "no changes made: the replacement(s) produced identical content"}};
}
if (!io->write_file(path, new_content)) {
return {{"error", "failed to write file: " + path}};
}
return {{"result", "file edited successfully"}, {"path", path}, {"edits_applied", (int) matched.size()}};
}
private:
// strip trailing whitespace, normalize smart quotes/dashes/spaces to ASCII
static std::string normalize_line_for_fuzzy_match(const std::string & line) {
size_t end = line.size();
while (end > 0 && (line[end - 1] == ' ' || line[end - 1] == '\t' || line[end - 1] == '\r')) {
end--;
}
std::string s = line.substr(0, end);
auto replace_all = [](std::string & str, const std::string & from, const std::string & to) {
if (from.empty()) return;
size_t pos = 0;
while ((pos = str.find(from, pos)) != std::string::npos) {
str.replace(pos, from.size(), to);
pos += to.size();
}
};
// smart single quotes -> '
for (unsigned char b : {0x98, 0x99, 0x9A, 0x9B}) {
replace_all(s, std::string("\xE2\x80") + (char) b, "'");
}
// smart double quotes -> "
for (unsigned char b : {0x9C, 0x9D, 0x9E, 0x9F}) {
replace_all(s, std::string("\xE2\x80") + (char) b, "\"");
}
// various dashes -> -
for (unsigned char b = 0x90; b <= 0x95; b++) {
replace_all(s, std::string("\xE2\x80") + (char) b, "-");
}
replace_all(s, "\xE2\x88\x92", "-"); // minus sign
// special spaces -> ' '
replace_all(s, "\xC2\xA0", " "); // no-break space
for (unsigned char b = 0x82; b <= 0x8A; b++) {
replace_all(s, std::string("\xE2\x80") + (char) b, " ");
}
replace_all(s, "\xE2\x80\xAF", " "); // narrow no-break space
replace_all(s, "\xE2\x81\x9F", " "); // medium mathematical space
replace_all(s, "\xE3\x80\x80", " "); // ideographic space
return s;
}
// applies the per-line transform above to every line; preserves line count/positions
static std::string normalize_for_fuzzy_match(const std::string & content) {
std::string result;
result.reserve(content.size());
size_t start = 0;
while (true) {
size_t nl = content.find('\n', start);
bool is_last = nl == std::string::npos;
std::string line = is_last ? content.substr(start) : content.substr(start, nl - start);
result += normalize_line_for_fuzzy_match(line);
if (is_last) break;
result += '\n';
start = nl + 1;
}
return result;
}
// lines with trailing '\n' kept, so untouched ones can be reconstructed verbatim
static std::vector<std::string> split_lines_with_endings(const std::string & content) {
std::vector<std::string> lines;
size_t start = 0;
while (start < content.size()) {
size_t nl = content.find('\n', start);
if (nl == std::string::npos) {
lines.push_back(content.substr(start));
break;
}
lines.push_back(content.substr(start, nl - start + 1));
start = nl + 1;
}
return lines;
}
struct line_span {
size_t start;
size_t end;
};
static std::vector<line_span> get_line_spans(const std::string & content) {
std::vector<line_span> spans;
size_t offset = 0;
for (const auto & line : split_lines_with_endings(content)) {
spans.push_back({offset, offset + line.size()});
offset += line.size();
}
return spans;
}
// count non-overlapping occurrences of `needle` in `content`
static size_t count_occurrences(const std::string & content, const std::string & needle) {
if (needle.empty()) return 0;
size_t count = 0, pos = 0;
while ((pos = content.find(needle, pos)) != std::string::npos) {
count++;
pos += needle.size();
}
return count;
}
struct matched_edit {
size_t edit_index;
size_t match_index; // offset into the "base content" (see below)
size_t match_length;
std::string new_text;
};
// replacements must be sorted ascending by match_index and non-overlapping
static std::string apply_replacements(
const std::string & content,
const std::vector<matched_edit> & replacements,
size_t offset) {
std::string result = content;
for (auto it = replacements.rbegin(); it != replacements.rend(); ++it) {
size_t local_index = it->match_index - offset;
result = result.substr(0, local_index) + it->new_text + result.substr(local_index + it->match_length);
}
return result;
}
// widen a replacement's byte range to the line(s) of `lines` it touches
static bool get_replacement_line_range(
const std::vector<line_span> & lines,
size_t match_index, size_t match_length,
size_t & out_start_line, size_t & out_end_line /* exclusive */) {
size_t replacement_start = match_index;
size_t replacement_end = match_index + match_length;
size_t start_line = (size_t) -1;
for (size_t i = 0; i < lines.size(); i++) {
if (replacement_start >= lines[i].start && replacement_start < lines[i].end) {
start_line = i;
break;
}
}
if (start_line == (size_t) -1) return false;
size_t end_line = start_line;
while (end_line < lines.size() && lines[end_line].end < replacement_end) {
end_line++;
}
if (end_line >= lines.size()) return false;
out_start_line = start_line;
out_end_line = end_line + 1;
return true;
}
// like apply_replacements, but untouched lines come from `original_content`
static std::string apply_replacements_preserving_unchanged_lines(
const std::string & original_content,
const std::string & base_content,
const std::vector<matched_edit> & replacements /* ascending, non-overlapping */) {
auto original_lines = split_lines_with_endings(original_content);
auto base_lines = get_line_spans(base_content);
struct group {
size_t start_line;
size_t end_line; // exclusive
std::vector<matched_edit> reps;
};
std::vector<group> groups;
for (const auto & rep : replacements) {
size_t start_line = 0, end_line = 0;
get_replacement_line_range(base_lines, rep.match_index, rep.match_length, start_line, end_line);
if (!groups.empty() && start_line < groups.back().end_line) {
groups.back().end_line = std::max(groups.back().end_line, end_line);
groups.back().reps.push_back(rep);
} else {
groups.push_back({start_line, end_line, {rep}});
}
}
size_t original_line_index = 0;
std::string result;
for (auto & g : groups) {
for (size_t i = original_line_index; i < g.start_line; i++) {
result += original_lines[i];
}
size_t group_start_offset = base_lines[g.start_line].start;
size_t group_end_offset = base_lines[g.end_line - 1].end;
std::string slice = base_content.substr(group_start_offset, group_end_offset - group_start_offset);
result += apply_replacements(slice, g.reps, group_start_offset);
original_line_index = g.end_line;
}
for (size_t i = original_line_index; i < original_lines.size(); i++) {
result += original_lines[i];
}
return result;
}
};
//
// get_datetime: returns the current date and time
//
struct server_tool_get_datetime : server_tool {
server_tool_get_datetime() {
name = "get_datetime";
display_name = "Get Date & Time";
permission_write = false;
}
json get_definition() const override {
return {
{"type", "function"},
{"function", {
{"name", name},
{"description", "Returns the current date and time in UTC"},
{"parameters", {
{"type", "object"},
{"properties", {
{"format", {
{"type", "string"},
{"description",
"strftime()-style format string for the output (default: \"%Y-%m-%dT%H:%M:%SZ\", "
"e.g. ISO 8601). Choose your own format if you need something else, "
"e.g. \"%A, %B %d %Y\" for a human-readable date."},
}},
}},
}},
}},
};
}
json invoke(json params, server_tool::stream *) const override {
std::string format = json_value(params, "format", std::string("%Y-%m-%dT%H:%M:%SZ"));
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
std::tm tm_utc;
#ifdef _WIN32
gmtime_s(&tm_utc, &time);
#else
gmtime_r(&time, &tm_utc);
#endif
char buf[256];
size_t len = std::strftime(buf, sizeof(buf), format.c_str(), &tm_utc);
if (len == 0) {
return {{"error", "invalid format string"}};
}
return {{"result", std::string(buf, len)}};
}
};
struct server_tool_stream_result : server_task_result {
std::string chunk;
bool done = false;
std::string error_msg;
json to_json() override {
if (!done) {
return {{"chunk", chunk}};
} else {
json result = {{"done", true}};
if (!error_msg.empty()) {
result["error"] = error_msg;
}
return result;
}
}
};
void server_tool::stream::push(const std::string & chunk) {
if (chunk.empty()) return;
auto r = std::make_unique<server_tool_stream_result>();
r->id = id;
r->chunk = chunk;
qr.send(std::move(r));
}
struct server_tools_res : server_http_res {
std::thread worker;
server_response * qr = nullptr; // set only for streaming responses
int id = -1;
~server_tools_res() override {
if (worker.joinable()) {
worker.join();
}
if (qr) {
qr->remove_waiting_task_id(id);
}
}
};
//
// server_mcp_tool: exposes one tool from a running MCP server as a server_tool.
//
struct server_mcp_tool : server_tool {
std::string server_name;
std::string tool_name;
server_mcp_tool_def def;
server_mcp & mcp_mgr;
server_mcp_tool(server_mcp_tool_def d, server_mcp & mgr)
: server_name(d.server_name)
, tool_name(d.name)
, def(std::move(d))
, mcp_mgr(mgr)
{
name = server_name + "_" + tool_name;
display_name = name;
permission_write = false;
support_stream = false;
}
std::string type() const override { return "mcp"; }
json get_definition() const override {
json schema = def.input_schema;
if (schema.is_null() || !schema.is_object()) {
schema = json::object();
}
return {
{"type", "function"},
{"function", {
{"name", name},
{"description", def.description},
{"parameters", schema},
}},
};
}
json invoke(json params, server_tool::stream *) const override {
return mcp_mgr.call_tool(server_name, tool_name, params);
}
};
static server_tool & find_tool(std::vector<std::unique_ptr<server_tool>> & tools, const std::string & name, bool require_stream) {
for (auto & t : tools) {
if (t->name == name) {
if (require_stream && !t->support_stream) {
throw std::invalid_argument(string_format("tool \"%s\" does not support stream = true", name.c_str()));
}
return *t;
}
}
throw std::invalid_argument(string_format("unknown tool \"%s\"", name.c_str()));
}
//
// public API
//
static std::vector<std::unique_ptr<server_tool>> build_tools() {
std::vector<std::unique_ptr<server_tool>> tools;
tools.push_back(std::make_unique<server_tool_read_file>());
tools.push_back(std::make_unique<server_tool_file_glob_search>());
tools.push_back(std::make_unique<server_tool_grep_search>());
tools.push_back(std::make_unique<server_tool_exec_shell_command>());
tools.push_back(std::make_unique<server_tool_write_file>());
tools.push_back(std::make_unique<server_tool_edit_file>());
tools.push_back(std::make_unique<server_tool_get_datetime>());
return tools;
}
void server_tools::setup(const std::vector<std::string> & enabled_tools,
server_mcp & mcp_mgr) {
if (!enabled_tools.empty()) {
std::unordered_set<std::string> enabled_set(enabled_tools.begin(), enabled_tools.end());
auto all_tools = build_tools();
// collect all known tool names for validation
std::vector<std::string> known_names;
known_names.reserve(all_tools.size());
for (const auto & t : all_tools) {
known_names.push_back(t->name);
}
// validate that every requested tool is known
for (const auto & name : enabled_tools) {
if (name == "all") continue;
if (std::find(known_names.begin(), known_names.end(), name) == known_names.end()) {
throw std::runtime_error(string_format(
"unknown tool \"%s\". available tools: %s",
name.c_str(),
string_join(known_names, ", ").c_str()));
}
}
tools.clear();
for (auto & t : all_tools) {
if (enabled_set.count(t->name) > 0 || enabled_set.count("all") > 0) {
tools.push_back(std::move(t));
}
}
}
// append MCP tools, skipping any that collide with a built-in or another MCP tool of the same "<server>_<tool>" name
if (!mcp_mgr.empty()) {
std::unordered_set<std::string> seen_names;
for (auto & t : tools) {
seen_names.insert(t->name);
}
size_t n_added = 0;
for (const auto & def : mcp_mgr.list_tools()) {
std::string mcp_name = def.server_name + "_" + def.name;
if (seen_names.count(mcp_name)) {
SRV_WRN("MCP tool \"%s\" from server \"%s\" collides with an existing tool, skipping\n",
mcp_name.c_str(), def.server_name.c_str());
continue;
}
seen_names.insert(mcp_name);
tools.push_back(std::make_unique<server_mcp_tool>(def, mcp_mgr));
n_added++;
}
if (n_added > 0) {
SRV_INF("Added %zu MCP tools\n", n_added);
}
}
handle_get = [this](const server_http_req &) -> server_http_res_ptr {
auto res = std::make_unique<server_http_res>();
try {
json result = json::array();
for (const auto & t : tools) {
result.push_back(t->to_json());
}
res->data = safe_json_to_str(result);
} catch (const std::exception & e) {
SRV_ERR("got exception: %s\n", e.what());
res->status = 500;
res->data = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_SERVER));
}
return res;
};
handle_post = [this](const server_http_req & req) -> server_http_res_ptr {
auto res = std::make_unique<server_tools_res>();
try {
json body = json::parse(req.body);
std::string tool_name = body.at("tool").get<std::string>();
json params = body.value("params", json::object());
bool stream = body.value("stream", false);
server_tool & tool = find_tool(tools, tool_name, stream);
if (stream) {
int id = res_id.fetch_add(1);
queue_res.add_waiting_task_id(id);
res->qr = &queue_res;
res->id = id;
res->worker = std::thread([this, id, &req, &tool, params]() mutable {
server_tool::stream st{queue_res, id, [&req]() {
return !req.should_stop();
}};
auto done = std::make_unique<server_tool_stream_result>();
try {
tool.invoke(params, &st);
} catch (const std::exception & e) {
done->error_msg = e.what();
} catch (...) {
done->error_msg = "An unknown error occurred";
}
done->id = st.id;
done->done = true;
st.qr.send(std::move(done));
});
res->content_type = "text/event-stream";
res->status = 200;
res->next = [this, id](std::string & output) -> bool {
auto result = queue_res.recv(id);
auto * r = dynamic_cast<server_tool_stream_result *>(result.get());
GGML_ASSERT(r != nullptr);
output = "data: " + safe_json_to_str(r->to_json()) + "\n\n";
if (r->done) {
queue_res.remove_waiting_task_id(id);
return false;
}
return true;
};
} else {
json result = tool.invoke(params, nullptr);
res->status = 200;
res->data = safe_json_to_str(result);
}
} catch (const json::exception & e) {
res->status = 400;
res->data = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST));
} catch (const std::invalid_argument & e) {
res->status = 404;
res->data = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST));
} catch (const std::exception & e) {
SRV_ERR("got exception: %s\n", e.what());
res->status = 500;
res->data = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_SERVER));
}
return res;
};
}