Skip to content

Commit b2a63d5

Browse files
fix: refuse to silently overwrite existing files in save_data (#539)
Add overwrite flag (default false) + expanduser. Fixes #539. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 052a4c5 commit b2a63d5

3 files changed

Lines changed: 60 additions & 16 deletions

File tree

src/sktime_mcp/server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,14 @@ async def list_tools() -> list[Tool]:
751751
"enum": ["csv", "parquet", "json"],
752752
"default": "csv",
753753
},
754+
"overwrite": {
755+
"type": "boolean",
756+
"description": (
757+
"Replace the file if it already exists. Default false — an "
758+
"existing file is not clobbered unless this is true."
759+
),
760+
"default": False,
761+
},
754762
},
755763
"required": ["data_handle", "path"],
756764
},
@@ -1116,6 +1124,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
11161124
data_handle=arguments["data_handle"],
11171125
path=arguments["path"],
11181126
format=arguments.get("format", "csv"),
1127+
overwrite=arguments.get("overwrite", False),
11191128
)
11201129

11211130
elif name == "auto_format_on_load":

src/sktime_mcp/tools/save_data.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def save_data_tool(
2626
data_handle: str,
2727
path: str,
2828
format: str = "csv",
29+
overwrite: bool = False,
2930
) -> dict[str, Any]:
3031
"""Persist the data behind a handle to a local file.
3132
@@ -42,6 +43,9 @@ def save_data_tool(
4243
use the `format` argument instead.
4344
format : str, default="csv"
4445
Output format. Must be one of: "csv", "parquet", or "json".
46+
overwrite : bool, default=False
47+
If the target file already exists, the write is refused unless this
48+
is True, in which case the response reports ``overwritten: true``.
4549
4650
Returns
4751
-------
@@ -52,6 +56,7 @@ def save_data_tool(
5256
- ``"saved_path"`` (str) -- Absolute path to the written file.
5357
- ``"format"`` (str) -- The format used to write the file.
5458
- ``"rows"`` (int) -- Number of rows written to the file.
59+
- ``"overwritten"`` (bool) -- True if an existing file was replaced.
5560
- ``"error"`` (str, optional) -- Error message if "success" is False.
5661
"""
5762
executor = get_executor()
@@ -70,6 +75,20 @@ def save_data_tool(
7075
"error": f"Unsupported format '{format}'. Choose from: {list(_FORMAT_WRITERS.keys())}",
7176
}
7277

78+
# Resolve (expanduser so "~/x" doesn't create a literal "~" dir) and guard
79+
# against silently clobbering an existing file (#539).
80+
abs_path = Path(path).expanduser().resolve()
81+
existed = abs_path.exists()
82+
if existed and not overwrite:
83+
return {
84+
"success": False,
85+
"error": (
86+
f"File already exists: '{abs_path}'. Pass overwrite=true to replace it, "
87+
"or choose a different path."
88+
),
89+
"saved_path": str(abs_path),
90+
}
91+
7392
data_info = executor._data_handles[data_handle]
7493
y = data_info["y"]
7594
X = data_info.get("X")
@@ -88,7 +107,6 @@ def save_data_tool(
88107
df = pd.concat([df, X], axis=1)
89108

90109
# Ensure target directory exists
91-
abs_path = Path(path).resolve()
92110
abs_path.parent.mkdir(parents=True, exist_ok=True)
93111

94112
# Write
@@ -106,6 +124,7 @@ def save_data_tool(
106124
"saved_path": str(abs_path),
107125
"format": fmt,
108126
"rows": len(df),
127+
"overwritten": existed,
109128
}
110129

111130
except Exception as e:

tests/test_data_management.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -235,31 +235,47 @@ def test_save_csv(self):
235235
executor, handle = _make_executor_with_data()
236236
self._patch(executor)
237237
try:
238-
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
239-
path = f.name
240-
result = save_data_tool(handle, path=path, format="csv")
238+
with tempfile.TemporaryDirectory() as d:
239+
path = str(Path(d) / "out.csv")
240+
result = save_data_tool(handle, path=path, format="csv")
241+
assert result["success"]
242+
assert result["format"] == "csv"
243+
assert result["rows"] == 60
244+
assert result["overwritten"] is False
245+
assert Path(result["saved_path"]).exists()
241246
finally:
242247
self._unpatch()
243248

244-
assert result["success"]
245-
assert result["format"] == "csv"
246-
assert result["rows"] == 60
247-
assert Path(result["saved_path"]).exists()
248-
Path(path).unlink()
249-
250249
def test_save_json(self):
251250
executor, handle = _make_executor_with_data()
252251
self._patch(executor)
253252
try:
254-
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
255-
path = f.name
256-
result = save_data_tool(handle, path=path, format="json")
253+
with tempfile.TemporaryDirectory() as d:
254+
path = str(Path(d) / "out.json")
255+
result = save_data_tool(handle, path=path, format="json")
256+
assert result["success"]
257+
assert result["format"] == "json"
257258
finally:
258259
self._unpatch()
259260

260-
assert result["success"]
261-
assert result["format"] == "json"
262-
Path(path).unlink()
261+
def test_save_refuses_existing_file(self):
262+
executor, handle = _make_executor_with_data()
263+
self._patch(executor)
264+
try:
265+
with tempfile.TemporaryDirectory() as d:
266+
path = str(Path(d) / "out.csv")
267+
first = save_data_tool(handle, path=path, format="csv")
268+
assert first["success"]
269+
# second write to the same path is refused without overwrite
270+
second = save_data_tool(handle, path=path, format="csv")
271+
assert not second["success"]
272+
assert "already exists" in second["error"]
273+
# ...and allowed with overwrite=True
274+
third = save_data_tool(handle, path=path, format="csv", overwrite=True)
275+
assert third["success"]
276+
assert third["overwritten"] is True
277+
finally:
278+
self._unpatch()
263279

264280
def test_save_unsupported_format(self):
265281
executor, handle = _make_executor_with_data()

0 commit comments

Comments
 (0)