From 0369dda84c20f9b52005c077b03664760b65243b Mon Sep 17 00:00:00 2001 From: jepegit Date: Tue, 22 Sep 2026 12:42:13 +0200 Subject: [PATCH] Add find_cells MCP tool without a silent raw crawl. Lists local files by project and number range. An empty cellpy result sets offer_raw; remote cellpydatadir is reported, not walked. Co-authored-by: Cursor --- README.md | 1 + src/cellpy_mcp/cells.py | 65 +++++++++++++++++++++++++++++++- tests/test_cell_tools.py | 80 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3184d9f..c233c28 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Cells and figures: | Tool | What it gives you | |---|---| | `list_instruments` | loaders, and whether each can actually run on this machine | +| `find_cells` | local files by project + number range; empty cellpy → `offer_raw` | | `load_cell` | a handle, cycle count, mass, summary column names | | `list_cells` | what is loaded | | `describe_plot_families` | the 20 summary families, marked available or missing-columns | diff --git a/src/cellpy_mcp/cells.py b/src/cellpy_mcp/cells.py index 6c84290..8c60d5e 100644 --- a/src/cellpy_mcp/cells.py +++ b/src/cellpy_mcp/cells.py @@ -17,7 +17,7 @@ from typing import Any -from .sandbox import Refused +from .sandbox import Refused, _is_local_dir __all__ = ["register", "MAX_PREVIEW_ROWS"] @@ -56,6 +56,69 @@ def arbin_res_readable() -> bool: out.append(item) return {"instruments": out, **sandbox.describe()} + @server.tool() + def find_cells( + project: str, + number_min: int, + number_max: int, + kind: str = "cellpy", + ) -> dict: + """List local cellpy or raw files for a project token and number range. + + Paths come from cellpy config. `kind="cellpy"` never searches raw + files. An empty cellpy result sets `offer_raw` so you can ask the + user before calling again with `kind="raw"`. + """ + from cellpy import config, filefinder + + if kind not in ("cellpy", "raw"): + raise Refused("kind must be 'cellpy' or 'raw'.") + + setting = "cellpydatadir" if kind == "cellpy" else "rawdatadir" + configured = getattr(config.paths, setting, None) + if configured is not None and not _is_local_dir(configured): + return { + "found": 0, + "cells": [], + "offer_raw": kind == "cellpy", + "remote": True, + "reason": ( + f"{setting} is a remote URI; this server only walks " + "local directories." + ), + **sandbox.describe(), + } + + finder = getattr(filefinder, "find_by_project", None) + if finder is None: + raise Refused( + "This cellpy has no filefinder.find_by_project " + "(needs jepegit/cellpy#1076)." + ) + + hits = finder(project, number_min, number_max, kind=kind) + cells_out = [] + for hit in hits: + try: + path = sandbox.resolve(hit["path"]) + except Refused: + continue + cells_out.append( + { + "name": hit["name"], + "number": hit["number"], + "path": str(path), + } + ) + empty = not cells_out + return { + "found": len(cells_out), + "cells": cells_out, + "offer_raw": kind == "cellpy" and empty, + "remote": False, + **sandbox.describe(), + } + @server.tool() def load_cell( path: str, diff --git a/tests/test_cell_tools.py b/tests/test_cell_tools.py index 8d4cf44..9389a25 100644 --- a/tests/test_cell_tools.py +++ b/tests/test_cell_tools.py @@ -7,6 +7,86 @@ pytestmark = pytest.mark.essential +def test_find_cells_returns_hits(drive, root, monkeypatch): + target = root / "20240922_SAL12.cellpy" + target.write_text("x") + + def fake(project, number_min, number_max, *, kind="cellpy", root=None): + return [ + { + "path": str(target), + "name": target.name, + "number": 12, + "kind": kind, + } + ] + + monkeypatch.setattr("cellpy.filefinder.find_by_project", fake, raising=False) + + async def steps(call): + return await call( + "find_cells", project="SAL", number_min=10, number_max=15 + ) + + out = drive(steps) + assert out["found"] == 1 + assert out["offer_raw"] is False + assert out["remote"] is False + assert out["cells"][0]["number"] == 12 + assert out["cells"][0]["name"] == "20240922_SAL12.cellpy" + + +def test_find_cells_empty_offers_raw_and_does_not_list_raw(drive, monkeypatch): + kinds = [] + + def fake(project, number_min, number_max, *, kind="cellpy", root=None): + kinds.append(kind) + return [] + + monkeypatch.setattr("cellpy.filefinder.find_by_project", fake, raising=False) + + async def steps(call): + return await call( + "find_cells", project="SAL", number_min=10, number_max=15 + ) + + out = drive(steps) + assert kinds == ["cellpy"] + assert out["found"] == 0 + assert out["cells"] == [] + assert out["offer_raw"] is True + + +def test_find_cells_remote_cellpy_dir_is_honest(drive, monkeypatch): + from cellpy import config + + monkeypatch.setattr(config.paths, "cellpydatadir", "scp://host/data") + + async def steps(call): + return await call( + "find_cells", project="SAL", number_min=10, number_max=15 + ) + + out = drive(steps) + assert out["found"] == 0 + assert out["remote"] is True + assert "remote" in out["reason"] + assert out["offer_raw"] is True + + +def test_find_cells_rejects_unknown_kind(drive): + async def steps(call): + return await call( + "find_cells", + project="SAL", + number_min=10, + number_max=15, + kind="journal", + ) + + assert "cellpy" in drive(steps)["refused"] + + def test_the_whole_arc(drive, demo_cell, root): """Load -> collect -> render -> export, as a client actually does it."""