Compare commits

..

1 Commits

Author SHA1 Message Date
Xuan-Son Nguyen f2b52a87e8 server: (tools) add x-tool-cwd header (#26420)
* server: (tools) add x-tool-cwd header

* reuse str_to_lower from server-models
2026-08-03 10:47:21 +02:00
3 changed files with 75 additions and 13 deletions
+3
View File
@@ -199,6 +199,9 @@ Invoke a tool call, request body is a JSON object with:
- `tool` (string): the name of the tool
- `params` (object): a mapping from argument name (string) to argument value
Headers:
- `x-tool-cwd`: optional; if set, use as the CWD for tool; this is not part of tool's params because it's meant to be set by the runtime, not the LLM itself
Returns JSON object. There are two response formats (MCP tools use the same two formats: their result content is concatenated into `plain_text_response`, and RPC or tool errors are surfaced as the `error` string):
Format 1: Plain text. The text will be placed into a field called `plain_text_response`, example:
+47 -11
View File
@@ -64,24 +64,27 @@ public:
class tools_io_basic : public tools_io {
public:
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
explicit tools_io_basic(std::string cwd = "") : cwd(std::move(cwd)) {}
bool is_directory(const std::string & path) const override {
std::error_code ec;
return fs::is_directory(path, ec) && !ec;
return fs::is_directory(resolve(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;
return fs::is_regular_file(resolve(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);
out_size = fs::file_size(resolve(path), ec);
return !ec;
}
bool read_file(const std::string & path, std::string & out) const override {
std::ifstream f(path, std::ios::binary);
std::ifstream f(resolve(path), std::ios::binary);
if (!f) return false;
std::ostringstream ss;
ss << f.rdbuf();
@@ -91,12 +94,12 @@ public:
bool write_file(const std::string & path, const std::string & content) const override {
std::error_code ec;
fs::path fpath(path);
fs::path fpath(resolve(path));
if (fpath.has_parent_path()) {
fs::create_directories(fpath.parent_path(), ec);
if (ec) return false;
}
std::ofstream f(path, std::ios::binary);
std::ofstream f(fpath, std::ios::binary);
if (!f) return false;
f << content;
return (bool) f;
@@ -104,13 +107,14 @@ public:
std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
err.clear();
std::string abs_base = resolve(base);
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"},
{"git", "-C", abs_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) {
@@ -128,7 +132,7 @@ public:
return result;
}
return list_files_fallback(base);
return list_files_fallback(abs_base);
}
exec_result run(
@@ -145,7 +149,7 @@ public:
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (!proc.create(args, options)) {
if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
res.output = "failed to spawn process";
return res;
}
@@ -205,6 +209,16 @@ public:
}
private:
std::string cwd;
// resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged
std::string resolve(const std::string & path) const {
if (cwd.empty() || fs::path(path).is_absolute()) {
return path;
}
return (fs::path(cwd) / path).string();
}
static const std::unordered_set<std::string> & junk_dir_names() {
static const std::unordered_set<std::string> names = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
@@ -244,8 +258,8 @@ private:
};
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>();
std::string cwd = json_value(params, "cwd", std::string());
return std::make_unique<tools_io_basic>(cwd);
}
// no '/' in pattern -> match basename at any depth; else match full relative path
@@ -1188,6 +1202,22 @@ static std::vector<std::unique_ptr<server_tool>> build_tools() {
return tools;
}
static std::string str_to_lower(const std::string & value) {
std::string lowered(value.size(), '\0');
std::transform(value.begin(), value.end(), lowered.begin(), [](unsigned char c) { return std::tolower(c); });
return lowered;
}
static std::string get_header(const std::map<std::string, std::string> & headers, const std::string & key, std::string default_value = "") {
const auto lowered_key = str_to_lower(key);
for (const auto & h : headers) {
if (str_to_lower(h.first) == lowered_key) {
return h.second;
}
}
return default_value;
}
void server_tools::setup(const std::vector<std::string> & enabled_tools,
server_mcp & mcp_mgr) {
if (!enabled_tools.empty()) {
@@ -1271,6 +1301,12 @@ void server_tools::setup(const std::vector<std::string> & enabled_tools,
json params = body.value("params", json::object());
bool stream = body.value("stream", false);
// accept x-tool-cwd header to override of the process
auto cwd = get_header(req.headers, "x-tool-cwd");
if (!cwd.empty()) {
params["cwd"] = cwd;
}
server_tool & tool = find_tool(tools, tool_name, stream);
if (stream) {
+25 -2
View File
@@ -19,8 +19,8 @@ def create_server():
server.server_tools = "all"
def call_tool(name: str, params: dict) -> dict:
res = server.make_request("POST", "/tools", data={"tool": name, "params": params})
def call_tool(name: str, params: dict, headers: dict | None = None) -> dict:
res = server.make_request("POST", "/tools", data={"tool": name, "params": params}, headers=headers)
assert res.status_code == 200, res.body
assert "error" not in res.body, res.body
return res.body
@@ -123,6 +123,29 @@ def test_tools_builtin_exec_shell_command_stream():
assert "[exit code: 0]" in chunks
def test_tools_builtin_cwd_header():
global server
server.start()
cwd_dir = os.path.join(PROJECT_ROOT, "tools", "server", "tests", "unit")
headers = {"x-tool-cwd": cwd_dir}
res = call_tool("read_file", {"path": "test_tools_builtin.py"}, headers=headers)
assert GREP_MARKER in res["plain_text_response"]
# exec_shell_command should also run with that directory as its working directory:
# writing to a relative filename must land inside cwd_dir
marker_name = "llama_cpp_test_tools_builtin_cwd_marker.txt"
marker_path = os.path.join(cwd_dir, marker_name)
try:
command = f"echo hello > {marker_name}"
call_tool("exec_shell_command", {"command": command}, headers=headers)
assert os.path.exists(marker_path)
finally:
if os.path.exists(marker_path):
os.remove(marker_path)
def test_tools_builtin_edit_file_rejects_overlapping_edits():
global server
server.start()