diff --git a/src/godotiq/tools/bridge/node_ops.py b/src/godotiq/tools/bridge/node_ops.py
new file mode 100644
index 0000000..fb58251
--- /dev/null
+++ b/src/godotiq/tools/bridge/node_ops.py
@@ -0,0 +1,36 @@
+"""Node operations tool -- batch node editing with undo/redo support."""
+
+from __future__ import annotations
+
+from godotiq.bridge.session import get_bridge
+
+MAX_OPERATIONS = 50
+
+
+async def node_ops(operations: list[dict]) -> dict:
+    """Execute batch node operations in the Godot editor.
+
+    Each operation is a dict with an 'op' key and operation-specific params.
+    All operations are grouped as a single undo action in the editor.
+
+    Supported operations:
+        move, rotate, scale, set_property, add_child, delete, duplicate, reparent
+
+    Args:
+        operations: List of operation dicts. Must be non-empty and at most 50 items.
+
+    Returns:
+        Dict with 'results' list containing per-operation status dicts.
+
+    Raises:
+        ValueError: If operations list is empty or exceeds the 50-operation limit.
+    """
+    if not operations:
+        raise ValueError("No operations provided; at least one operation is required.")
+    if len(operations) > MAX_OPERATIONS:
+        raise ValueError(
+            f"Too many operations ({len(operations)}); limit is {MAX_OPERATIONS} per call."
+        )
+
+    bridge = await get_bridge()
+    return await bridge.request("node_ops", params={"operations": operations})
diff --git a/tests/test_bridge/test_node_ops.py b/tests/test_bridge/test_node_ops.py
new file mode 100644
index 0000000..f32e65e
--- /dev/null
+++ b/tests/test_bridge/test_node_ops.py
@@ -0,0 +1,82 @@
+"""Tests for node_ops bridge tool implementation."""
+
+from __future__ import annotations
+
+import pytest
+from unittest.mock import AsyncMock, patch
+
+
+from godotiq.tools.bridge.node_ops import node_ops
+
+MAX_OPS = 50
+
+
+class TestNodeOps:
+    """node_ops parameter validation and bridge delegation."""
+
+    async def test_valid_operations_forwarded(self) -> None:
+        """A valid operations list is forwarded to bridge.request('node_ops', ...)."""
+        ops = [{"op": "move", "node": "Player", "position": [1, 2, 3]}]
+        mock_bridge = AsyncMock()
+        mock_bridge.request.return_value = {
+            "results": [{"op": "move", "node": "Player", "status": "ok"}]
+        }
+        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
+            result = await node_ops(operations=ops)
+
+        mock_bridge.request.assert_called_once_with(
+            "node_ops", params={"operations": ops}
+        )
+        assert result["results"][0]["status"] == "ok"
+
+    async def test_empty_operations_rejected(self) -> None:
+        """An empty operations list raises ValueError."""
+        with pytest.raises(ValueError, match="[Ee]mpty|[Nn]o operations|at least"):
+            await node_ops(operations=[])
+
+    async def test_limit_enforced(self) -> None:
+        """An operations list exceeding 50 items raises ValueError."""
+        ops = [{"op": "move", "node": f"N{i}", "position": [0, 0, 0]} for i in range(MAX_OPS + 1)]
+        with pytest.raises(ValueError, match="50|limit|exceed"):
+            await node_ops(operations=ops)
+
+    async def test_exactly_50_operations_allowed(self) -> None:
+        """Exactly 50 operations should be accepted (boundary test)."""
+        ops = [{"op": "move", "node": f"N{i}", "position": [0, 0, 0]} for i in range(MAX_OPS)]
+        mock_bridge = AsyncMock()
+        mock_bridge.request.return_value = {"results": []}
+        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
+            await node_ops(operations=ops)
+
+        mock_bridge.request.assert_called_once()
+
+    async def test_returns_bridge_response(self) -> None:
+        """The bridge response dict is returned as-is."""
+        expected = {"results": [{"op": "delete", "node": "Enemy", "status": "ok"}]}
+        mock_bridge = AsyncMock()
+        mock_bridge.request.return_value = expected
+        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
+            result = await node_ops(operations=[{"op": "delete", "node": "Enemy"}])
+
+        assert result == expected
+
+    async def test_bridge_connection_error_propagates(self) -> None:
+        """BridgeConnectionError from get_bridge propagates to caller."""
+        from godotiq.bridge.connection import BridgeConnectionError
+
+        with patch(
+            "godotiq.tools.bridge.node_ops.get_bridge",
+            side_effect=BridgeConnectionError("Not connected"),
+        ):
+            with pytest.raises(BridgeConnectionError):
+                await node_ops(operations=[{"op": "move", "node": "X", "position": [0, 0, 0]}])
+
+    async def test_bridge_timeout_error_propagates(self) -> None:
+        """BridgeTimeoutError from bridge.request propagates to caller."""
+        from godotiq.bridge.connection import BridgeTimeoutError
+
+        mock_bridge = AsyncMock()
+        mock_bridge.request.side_effect = BridgeTimeoutError("Timed out")
+        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
+            with pytest.raises(BridgeTimeoutError):
+                await node_ops(operations=[{"op": "move", "node": "X", "position": [0, 0, 0]}])
