Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/sktime_mcp/runtime/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,14 +866,13 @@ def call_method(
if isinstance(result, __import__("pandas").DataFrame) and isinstance(
result.columns, __import__("pandas").MultiIndex
):
result = result.copy()
result.columns = ["_".join(map(str, col)) for col in result.columns.values]
sanitized = result.to_dict(orient="list")
result = result.to_dict(orient="list")
else:
sanitized = result.to_dict()
else:
sanitized = sanitize_for_json(result)
result = result.to_dict()

return {"success": True, "result": sanitized}
return {"success": True, "result": sanitize_for_json(result)}
except Exception as e:
logger.error("%s failed: %s", type(e).__name__, e, exc_info=True)
return {"success": False, "error": str(e)}
Expand Down
4 changes: 3 additions & 1 deletion src/sktime_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def sanitize_for_json(obj, _seen=None):
Handles:
- Standard Python scalars and containers (dict, list, tuple)
- NumPy integer/float scalars and ndarrays
- Pandas Timestamp, NaT, NA, and Series/DataFrame
- Pandas Timestamp, NaT, NA, Series/DataFrame, Index
- Arbitrary objects (fallback to str repr)
- Circular references (returns a placeholder instead of recursing forever)
"""
Expand Down Expand Up @@ -196,6 +196,8 @@ def sanitize_for_json(obj, _seen=None):
return sanitize_for_json(obj.tolist(), _seen)
if isinstance(obj, pd.DataFrame):
return sanitize_for_json(obj.to_dict(orient="records"), _seen)
if isinstance(obj, pd.Index):
return [sanitize_for_json(item, _seen) for item in obj.tolist()]

# --- Standard Python containers ---
if isinstance(obj, dict):
Expand Down
63 changes: 63 additions & 0 deletions tests/test_call_method.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""call_method JSON sanitization beyond generator materialization."""

import json

import pandas as pd
from sktime.datasets import load_airline

from sktime_mcp.runtime.executor import get_executor
from sktime_mcp.tools.instantiate import instantiate_tool


def test_split_loc_returns_label_lists():
"""split_loc folds are label lists, not PeriodIndex repr strings."""
executor = get_executor()
inst = instantiate_tool(spec="SlidingWindowSplitter(window_length=24, step_length=12)")
assert inst["success"], inst
handle = inst["handle"]
try:
out = executor.call_method(handle, "split_loc", {"y_dataset": "airline"})
assert out["success"] is True, out
train, test = out["result"][0]
assert isinstance(train, list) and isinstance(test, list)
assert train and all(isinstance(x, str) for x in train + test)
json.dumps(out)
finally:
executor._handle_manager.release_handle(handle)


def test_to_dict_path_is_json_safe():
"""Series.to_dict() still goes through sanitize_for_json (PeriodIndex keys)."""
executor = get_executor()
inst = instantiate_tool(spec="NaiveForecaster()")
handle = inst["handle"]
try:
fit_res = executor.fit(handle, y=load_airline())
assert fit_res["success"], fit_res
out = executor.call_method(handle, "predict", {"fh": [1, 2, 3]})
assert out["success"] is True, out
json.dumps(out)
finally:
executor._handle_manager.release_handle(handle)


def test_multiindex_flatten_copies_frame():
"""Flattening MultiIndex columns must not mutate the live object."""
df = pd.DataFrame(
[[1.0, 2.0]],
columns=pd.MultiIndex.from_tuples([("Coverage", 0.9), ("Coverage", 0.1)]),
)

class _Frame:
def dump(self):
return df

executor = get_executor()
handle = executor._handle_manager.create_handle("Frame", _Frame(), {})
try:
out = executor.call_method(handle, "dump", {})
assert out["success"] is True, out
assert isinstance(df.columns, pd.MultiIndex)
json.dumps(out)
finally:
executor._handle_manager.release_handle(handle)
5 changes: 5 additions & 0 deletions tests/test_sanitize.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ def test_dataframe(self):
result = sanitize_for_json(df)
json.dumps(result)

def test_index(self):
result = sanitize_for_json(pd.period_range("1949-01", periods=3, freq="M"))
assert isinstance(result, list) and len(result) == 3
json.dumps(result)


class TestNestedToolOutput:
"""Realistic nested dicts like actual tool responses."""
Expand Down