From f2b52a87e82fa461191565a19d4ef9fd8b8fbd87 Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Mon, 3 Aug 2026 10:47:21 +0200 Subject: [PATCH] server: (tools) add x-tool-cwd header (#26420) * server: (tools) add x-tool-cwd header * reuse str_to_lower from server-models --- tools/server/README-dev.md | 3 + tools/server/server-tools.cpp | 58 +++++++++++++++---- tools/server/tests/unit/test_tools_builtin.py | 27 ++++++++- 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/tools/server/README-dev.md b/tools/server/README-dev.md index b41d70c63a..45bcdcca76 100644 --- a/tools/server/README-dev.md +++ b/tools/server/README-dev.md @@ -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: diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index 90b7e2a9f0..4a6c5ed442 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -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 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 & junk_dir_names() { static const std::unordered_set names = { ".git", ".svn", ".hg", "node_modules", "__pycache__", @@ -244,8 +258,8 @@ private: }; static std::unique_ptr make_tools_io(const json & params) { - GGML_UNUSED(params); // TODO in follow-up PR - return std::make_unique(); + std::string cwd = json_value(params, "cwd", std::string()); + return std::make_unique(cwd); } // no '/' in pattern -> match basename at any depth; else match full relative path @@ -1188,6 +1202,22 @@ static std::vector> 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 & 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 & enabled_tools, server_mcp & mcp_mgr) { if (!enabled_tools.empty()) { @@ -1271,6 +1301,12 @@ void server_tools::setup(const std::vector & 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) { diff --git a/tools/server/tests/unit/test_tools_builtin.py b/tools/server/tests/unit/test_tools_builtin.py index 1b2d0db432..fb194cac66 100755 --- a/tools/server/tests/unit/test_tools_builtin.py +++ b/tools/server/tests/unit/test_tools_builtin.py @@ -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()