diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index 8c5a794e6..994406132 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -474,23 +474,26 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d raw = raw[:MAX_NODE_FILE_BYTES] truncated = True - lines = raw.splitlines(keepends=False) + # keepends keeps the content byte-faithful: the trailing newline of the + # last line and any \r\n endings survive the round trip + lines = raw.splitlines(keepends=True) total_lines = len(lines) # Apply offset/limit selected = lines[offset: offset + limit] if offset < total_lines else [] has_more = (offset + limit) < total_lines or truncated + content = "".join(selected) return { "file_path": file_path, - "content": "\n".join(selected), + "content": content, "metadata": { "total_lines": total_lines, "total_bytes": total_bytes, "offset": offset, "limit": limit, "returned_lines": len(selected), - "returned_bytes": len("\n".join(selected).encode("utf-8")), + "returned_bytes": len(content.encode("utf-8")), "truncated": truncated or has_more, "has_more": has_more, }, diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 68ca430f9..c95846870 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -242,6 +242,49 @@ class TestNode: result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx) assert "command" in result + @staticmethod + def _file_conn(text): + conn = _mock_conn() + conn.http_call.return_value.text = text + return conn + + def test_file_get_keeps_trailing_newline(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2\n") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\nline2\n" + assert result["metadata"]["total_bytes"] == 12 + assert result["metadata"]["returned_bytes"] == 12 + assert result["metadata"]["has_more"] is False + + def test_file_get_keeps_crlf_endings(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\r\nline2\r\n") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\r\nline2\r\n" + assert result["metadata"]["returned_bytes"] == 14 + + def test_file_get_without_trailing_newline(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\nline2" + + def test_file_get_pagination(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2\nline3\n") + result = get_node_file_handler( + {"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg", "offset": 1, "limit": 1}, ctx + ) + assert result["content"] == "line2\n" + assert result["metadata"]["total_lines"] == 3 + assert result["metadata"]["returned_lines"] == 1 + assert result["metadata"]["has_more"] is True + # ── Link ────────────────────────────────────────────────────────────────