> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionworks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026 07 08 ionworks mcp read layer

# Ionworks MCP Read Layer Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build `packages/ionworks-mcp`, a lightweight read-only MCP server that exposes the Ionworks public API (cell-data hierarchy + materials) as 15 MCP tools by wrapping the existing `ionworks-api` Python SDK.

**Architecture:** A new uv-workspace package depending only on `ionworks-api` and the `mcp` SDK. A lazy singleton builds the `Ionworks` client from env vars and forces the pandas DataFrame backend. FastMCP tools (grouped by domain into `tools/*.py`) delegate to SDK sub-clients; a shared `serialize.py` normalizes `PaginatedList` / Pydantic models / DataFrames to JSON-safe dicts with mandatory row-windowing on DataFrames; a `@tool_errors` decorator maps `IonworksError` to structured error payloads. Runs over stdio.

**Tech Stack:** Python 3.11+, `mcp` (FastMCP), `ionworks-api` (workspace), pandas, pytest. Built with hatchling, tested via `just test-mcp`.

**Spec:** `docs/superpowers/specs/2026-07-08-ionworks-mcp-read-layer-design.md`

***

## Reference: verified SDK facts (do not re-derive)

* Client entry: `from ionworks import Ionworks`. Sub-clients: `client.cell_spec`, `client.cell_instance`, `client.cell_measurement`, `client.material`, `client.material_property_dataset`. Client-level: `client.capabilities()`.
* `set_dataframe_backend` / `IonworksError` importable from top-level `ionworks` package (`from ionworks import set_dataframe_backend, IonworksError`).
* `PaginatedList` (`from ionworks import PaginatedList`) has ONLY `.items`, `.total`, and `len()` (`.count` via `__len__`). **No `.offset`.**
* Signatures (positional required arg first):
  * `cell_spec.list(include_components=False, limit=None, offset=None, *, name=None, name_exact=None, form_factor=None, ...)` → `PaginatedList[CellSpecification]`
  * `cell_spec.get(cell_spec_id)` → `CellSpecification` — **NOTE: no `include_components` param.** The spec's tool table lists `include_components` for `get_cell_spec`, but the SDK `get` does not accept it; implement `get_cell_spec(cell_spec_id)` only (list-level `include_components` still exists on `cell_spec.list`).
  * **`Ionworks()` raises a bare `ValueError` eagerly in `__init__`** (client.py:145-149) when no `IONWORKS_API_KEY` is set — NOT on first request. `get_client()` must translate this to a clear `IonworksError` (see Task 2).
  * `cell_instance.list(cell_spec_id, limit=None, offset=None, *, name=None, ...)` → `PaginatedList[CellInstance]`
  * `cell_instance.get(cell_instance_id)` → `CellInstance`
  * `cell_measurement.list(cell_instance_id, limit=None, offset=None, measurement_type=None, *, name=None, ...)` → `PaginatedList[CellMeasurement]`
  * `cell_measurement.get(measurement_id)` → `CellMeasurement`
  * `cell_measurement.time_series(measurement_id, use_cache=True)` → DataFrame (no columns/limit/offset at SDK level)
  * `cell_measurement.steps(measurement_id, use_cache=True)` → DataFrame
  * `cell_measurement.cycles(measurement_id, use_cache=True)` → DataFrame
  * `material.list(limit=None, offset=None, *, project_id=None)` → `PaginatedList[Material]`
  * `material.get(material_id)` → `Material`
  * `material_property_dataset.list(material_id, project_id=None, limit=None, offset=None)` → `PaginatedList[MaterialPropertyDataset]`
  * `material_property_dataset.get(dataset_id)` → `MaterialPropertyDataset`
  * `material_property_dataset.get_data(dataset_id)` → DataFrame
  * `material_property_dataset.get_units(dataset_id)` → `dict[str, str]`
* Pydantic models are v2: use `.model_dump(mode="json")`.
* DataFrame windowing constants: `DEFAULT_ROW_LIMIT = 500`, `MAX_ROW_LIMIT = 5000`.

***

## File Structure

* `packages/ionworks-mcp/pyproject.toml` — package metadata, deps, `dev` extra, entry point, pytest config
* `packages/ionworks-mcp/README.md` — usage + MCP client config + smoke test
* `packages/ionworks-mcp/ionworks_mcp/__init__.py` — version marker
* `packages/ionworks-mcp/ionworks_mcp/client.py` — `get_client()` lazy singleton, forces pandas backend
* `packages/ionworks-mcp/ionworks_mcp/errors.py` — `@tool_errors` decorator
* `packages/ionworks-mcp/ionworks_mcp/serialize.py` — `serialize_model`, `serialize_list`, `serialize_dataframe`
* `packages/ionworks-mcp/ionworks_mcp/tools/__init__.py` — `register_all(mcp)`
* `packages/ionworks-mcp/ionworks_mcp/tools/discovery.py` — `discover_capabilities`
* `packages/ionworks-mcp/ionworks_mcp/tools/cells.py` — 6 cell spec/instance/measurement list+get tools
* `packages/ionworks-mcp/ionworks_mcp/tools/measurement_data.py` — 3 time\_series/steps/cycles tools
* `packages/ionworks-mcp/ionworks_mcp/tools/materials.py` — 5 material + property-dataset tools
* `packages/ionworks-mcp/ionworks_mcp/server.py` — build FastMCP, `register_all`, `run()` stdio
* `packages/ionworks-mcp/ionworks_mcp/__main__.py` — `main()` console entry
* `packages/ionworks-mcp/tests/conftest.py` — fake client fixture + monkeypatch of `get_client`
* `packages/ionworks-mcp/tests/test_serialize.py` — serializer unit tests
* `packages/ionworks-mcp/tests/test_errors.py` — decorator unit tests
* `packages/ionworks-mcp/tests/test_tools.py` — per-tool delegation/serialization/error tests
* `packages/ionworks-mcp/tests/test_server.py` — all-15-tools-registered test
* `pyproject.toml` (root) — add `packages/ionworks-mcp` to workspace members
* `justfile` — add `test-mcp` recipe

**Tool → function mapping (15):** `discover_capabilities`; `list_cell_specs`, `get_cell_spec`, `list_cell_instances`, `get_cell_instance`, `list_measurements`, `get_measurement`; `get_measurement_time_series`, `get_measurement_steps`, `get_measurement_cycles`; `list_materials`, `get_material`, `list_material_property_datasets`, `get_material_property_dataset`, `get_material_property_data`.

***

## Task 1: Scaffold the package and wire it into the workspace

**Files:**

* Create: `packages/ionworks-mcp/pyproject.toml`

* Create: `packages/ionworks-mcp/ionworks_mcp/__init__.py`

* Modify: `pyproject.toml` (root) — workspace members list

* Modify: `justfile` — add `test-mcp` recipe

* [ ] **Step 1: Create `packages/ionworks-mcp/pyproject.toml`**

```toml theme={null}
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "ionworks-mcp"
version = "0.1.0"
description = "Read-only MCP server for the Ionworks public API"
authors = []
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "mcp>=1.2.0",
    "ionworks-api",
    "pandas>=2.0,<3",
]

[project.urls]
Homepage = "https://ionworks.com"
Repository = "https://github.com/ionworks/ionworks-app"

[project.scripts]
ionworks-mcp = "ionworks_mcp.__main__:main"

[project.optional-dependencies]
dev = [
    "pytest>=9.0",
    "pytest-cov>=7.0",
]

[tool.uv.sources]
ionworks-api = { workspace = true }

[tool.hatch.build.targets.wheel]
packages = ["ionworks_mcp"]

[tool.hatch.build.targets.sdist]
packages = ["ionworks_mcp"]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
pythonpath = ["."]
```

The `pythonpath = ["."]` makes the package dir the import root so tests can do `from tests._fakes import FakeModel` (see Task 2). Combined with `tests/__init__.py` (Task 2 Step 4) this makes `tests` an importable package under the `cd packages/ionworks-mcp && pytest` recipe.

* [ ] **Step 2: Create `packages/ionworks-mcp/ionworks_mcp/__init__.py`**

```python theme={null}
"""Read-only MCP server exposing the Ionworks public API."""

__version__ = "0.1.0"
```

* [ ] **Step 3: Add the package to the root workspace members**

In `pyproject.toml` (root), under `[tool.uv.workspace] members = [...]`, add the line `"packages/ionworks-mcp",` alongside the other `packages/*` entries.

* [ ] **Step 4: Add the `test-mcp` recipe to `justfile`**

After the `test-api` recipe, add:

```
test-mcp *args:
    just test-package ionworks-mcp {{args}}
```

* [ ] **Step 5: Sync the workspace so the new package + `mcp` resolve**

Run: `uv sync --all-packages`
Expected: completes without error; `ionworks-mcp` and `mcp` appear in the resolution. If `mcp>=1.2.0` fails to resolve, relax to the latest available `mcp` and note it.

* [ ] **Step 6: Verify and pin the FastMCP tool-enumeration accessor** (load-bearing for Tasks 5-8)

The test harness (`_tool_fns`, `test_server.py`) enumerates registered tools and their callables. The exact accessor is `mcp` version-dependent. Confirm it now, before any test depends on it:

Run:

```bash theme={null}
uv run --package ionworks-mcp python -c "
from mcp.server.fastmcp import FastMCP
m = FastMCP('probe')
@m.tool()
def sample(x: int) -> dict: return {'x': x}
tools = m._tool_manager.list_tools()
t = tools[0]
print('list_tools ok:', [x.name for x in tools])
print('has .fn:', hasattr(t, 'fn'), 'has .name:', hasattr(t, 'name'))
print('schema:', t.parameters)
"
```

Expected: prints `['sample']`, `has .fn: True has .name: True`, and a JSON schema containing `x`. **If the accessor differs** (e.g. `.fn` is `.func`, or enumeration is `m.list_tools()` async), record the working form here and use it consistently in `_tool_fns`, `test_server.py`, and the Task 9 smoke test. Do NOT proceed to Task 5 until this prints as expected.

* [ ] **Step 7: Commit**

```bash theme={null}
git add packages/ionworks-mcp/pyproject.toml packages/ionworks-mcp/ionworks_mcp/__init__.py pyproject.toml uv.lock justfile
git commit -m "chore(ionworks-mcp): scaffold package and wire into workspace"
```

***

## Task 2: `get_client()` lazy singleton (forces pandas backend)

**Files:**

* Create: `packages/ionworks-mcp/ionworks_mcp/client.py`

* Create: `packages/ionworks-mcp/tests/__init__.py` (empty — makes `tests` importable)

* Create: `packages/ionworks-mcp/tests/_fakes.py` (shared fake classes)

* Create: `packages/ionworks-mcp/tests/conftest.py` (fixtures)

* Test: `packages/ionworks-mcp/tests/test_tools.py` (client portion)

* [ ] **Step 1: Write the failing tests** in `tests/test_tools.py`

```python theme={null}
import pytest

import ionworks_mcp.client as client_mod
from ionworks import IonworksError


def test_get_client_forces_pandas_and_caches(monkeypatch):
    calls = {"backend": [], "ctor": 0}

    def fake_set_backend(name):
        calls["backend"].append(name)

    class FakeIonworks:
        def __init__(self, *a, **k):
            calls["ctor"] += 1

    monkeypatch.setattr(client_mod, "set_dataframe_backend", fake_set_backend)
    monkeypatch.setattr(client_mod, "Ionworks", FakeIonworks)
    client_mod._client = None  # reset singleton

    c1 = client_mod.get_client()
    c2 = client_mod.get_client()

    assert c1 is c2                      # cached
    assert calls["ctor"] == 1            # constructed once
    assert calls["backend"] == ["pandas"]  # pandas forced once


def test_get_client_missing_key_raises_clear_ionworks_error(monkeypatch):
    def fake_ctor(*a, **k):
        raise ValueError(
            "API key must be provided either as argument or in IONWORKS_API_KEY "
            "environment variable"
        )

    monkeypatch.setattr(client_mod, "set_dataframe_backend", lambda name: None)
    monkeypatch.setattr(client_mod, "Ionworks", fake_ctor)
    client_mod._client = None

    with pytest.raises(IonworksError) as exc:
        client_mod.get_client()
    assert "IONWORKS_API_KEY" in str(exc.value)
```

* [ ] **Step 2: Run it to verify it fails**

Run: `just test-mcp tests/test_tools.py -k get_client -v`
Expected: FAIL (module `ionworks_mcp.client` does not exist).

* [ ] **Step 3: Implement `client.py`**

The SDK's `Ionworks()` raises a **bare `ValueError` eagerly in `__init__`** when no key is set (verified: `packages/ionworks-api/ionworks/client.py:145-149`). Translate it to a clear `IonworksError` so the `@tool_errors` boundary reports it distinctly and the message is actionable (matches the spec's promised message).

```python theme={null}
"""Lazy, process-wide Ionworks SDK client for the MCP server."""

from __future__ import annotations

from ionworks import Ionworks, IonworksError, set_dataframe_backend

_client: Ionworks | None = None


def get_client() -> Ionworks:
    """Return a process-wide Ionworks client, constructing it on first use.

    The DataFrame backend is forced to pandas so the serialization layer can
    rely on a single, stable DataFrame API. Credentials are read from the
    environment by the SDK (``IONWORKS_API_KEY`` and optional
    ``IONWORKS_API_URL`` / project id).

    The SDK constructor raises a bare ``ValueError`` immediately when no key is
    present; this is re-raised as an ``IonworksError`` with an actionable
    message so the MCP error boundary reports it as a service error rather than
    a client "bad request".

    Returns
    -------
    Ionworks
        The shared client instance.

    Raises
    ------
    IonworksError
        If ``IONWORKS_API_KEY`` is not configured in the environment.
    """
    global _client
    if _client is None:
        set_dataframe_backend("pandas")
        try:
            _client = Ionworks()
        except ValueError as exc:
            raise IonworksError(
                "IONWORKS_API_KEY not set — configure it in your MCP server "
                f"environment. ({exc})"
            ) from exc
    return _client
```

* [ ] **Step 4: Create `tests/__init__.py`** (empty file) and **`tests/_fakes.py`**

`tests/__init__.py`: empty.

`tests/_fakes.py`:

```python theme={null}
"""Plain fake classes shared across tests (imported, not injected as fixtures)."""

from __future__ import annotations


class FakeModel:
    """Stand-in for a Pydantic v2 model exposing model_dump."""

    def __init__(self, data: dict):
        self._data = data

    def model_dump(self, mode: str = "python") -> dict:
        return dict(self._data)


class FakePaginatedList(list):
    """Behaves like PaginatedList: a list plus a .total attribute."""

    def __init__(self, items, total):
        super().__init__(items)
        self.total = total
```

* [ ] **Step 5: Create `tests/conftest.py` with the fixtures**

```python theme={null}
"""Shared test fixtures: a fake Ionworks client wired into get_client()."""

from __future__ import annotations

from types import SimpleNamespace

import pandas as pd
import pytest

import ionworks_mcp.client as client_mod


@pytest.fixture
def fake_client(monkeypatch):
    """Install a fake Ionworks client and return it for per-test configuration.

    Patches ``ionworks_mcp.client.get_client``; tools call it via
    ``client_mod.get_client()`` so the module-attribute patch is what they see.
    """
    fake = SimpleNamespace(
        cell_spec=SimpleNamespace(),
        cell_instance=SimpleNamespace(),
        cell_measurement=SimpleNamespace(),
        material=SimpleNamespace(),
        material_property_dataset=SimpleNamespace(),
        capabilities=lambda: {"name": "Ionworks", "hierarchy": "..."},
    )
    monkeypatch.setattr(client_mod, "get_client", lambda: fake)
    return fake


@pytest.fixture
def sample_df():
    return pd.DataFrame(
        {
            "Time [s]": [0.0, 1.0, 2.0],
            "Voltage [V]": [3.7, 3.71, 3.72],
            "Current [A]": [0.0, -1.0, -1.0],
        }
    )
```

* [ ] **Step 6: Run the client tests to verify they pass**

Run: `just test-mcp tests/test_tools.py -k get_client -v`
Expected: PASS (2 tests).

* [ ] **Step 7: Commit**

```bash theme={null}
git add packages/ionworks-mcp/ionworks_mcp/client.py packages/ionworks-mcp/tests/__init__.py packages/ionworks-mcp/tests/_fakes.py packages/ionworks-mcp/tests/conftest.py packages/ionworks-mcp/tests/test_tools.py
git commit -m "feat(ionworks-mcp): lazy client singleton (pandas backend, clear missing-key error)"
```

***

## Task 3: `@tool_errors` decorator

**Files:**

* Create: `packages/ionworks-mcp/ionworks_mcp/errors.py`

* Test: `packages/ionworks-mcp/tests/test_errors.py`

* [ ] **Step 1: Write the failing tests** in `tests/test_errors.py`

```python theme={null}
from ionworks import IonworksError

from ionworks_mcp.errors import tool_errors


def test_passes_through_success():
    @tool_errors
    def ok(x):
        return {"value": x}

    assert ok(3) == {"value": 3}


def test_wraps_ionworks_error():
    @tool_errors
    def boom():
        raise IonworksError("nope")

    result = boom()
    assert result["error"]["message"] == "nope"
    assert result["error"]["code"] == "IonworksError"


def test_wraps_value_error_as_bad_request():
    @tool_errors
    def bad():
        raise ValueError("missing material_id")

    result = bad()
    assert result["error"]["code"] == "BadRequest"
    assert "material_id" in result["error"]["message"]
```

* [ ] **Step 2: Run to verify failure**

Run: `just test-mcp tests/test_errors.py -v`
Expected: FAIL (module missing).

* [ ] **Step 3: Implement `errors.py`**

```python theme={null}
"""Error-boundary decorator translating exceptions into structured payloads."""

from __future__ import annotations

import functools
from collections.abc import Callable
from typing import Any

from ionworks import IonworksError


def tool_errors(func: Callable[..., Any]) -> Callable[..., Any]:
    """Wrap a tool so exceptions become structured error dicts, not crashes.

    ``IonworksError`` (and subclasses) map to their class name as the code.
    ``ValueError`` — raised by argument validation inside tools — maps to
    ``"BadRequest"``. Any other exception maps to ``"InternalError"``.
    """

    @functools.wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        try:
            return func(*args, **kwargs)
        except IonworksError as exc:
            # Use .message (the SDK's raw text attribute), NOT str(exc):
            # IonworksError.__str__ prepends "error code: {status_code}, " which
            # would leak into the MCP payload.
            return {"error": {"code": type(exc).__name__, "message": exc.message}}
        except ValueError as exc:
            return {"error": {"code": "BadRequest", "message": str(exc)}}
        except Exception as exc:  # noqa: BLE001 - MCP boundary must not crash
            return {"error": {"code": "InternalError", "message": str(exc)}}

    return wrapper
```

* [ ] **Step 4: Run to verify pass**

Run: `just test-mcp tests/test_errors.py -v`
Expected: PASS (3 tests).

* [ ] **Step 5: Commit**

```bash theme={null}
git add packages/ionworks-mcp/ionworks_mcp/errors.py packages/ionworks-mcp/tests/test_errors.py
git commit -m "feat(ionworks-mcp): tool_errors decorator for structured error payloads"
```

***

## Task 4: `serialize.py` — models, lists, DataFrames

**Files:**

* Create: `packages/ionworks-mcp/ionworks_mcp/serialize.py`

* Test: `packages/ionworks-mcp/tests/test_serialize.py`

* [ ] **Step 1: Write the failing tests** in `tests/test_serialize.py`

```python theme={null}
import pandas as pd

from ionworks_mcp.serialize import (
    DEFAULT_ROW_LIMIT,
    MAX_ROW_LIMIT,
    serialize_dataframe,
    serialize_list,
    serialize_model,
)
from tests._fakes import FakeModel, FakePaginatedList


def test_serialize_model():
    assert serialize_model(FakeModel({"id": "a", "name": "x"})) == {"id": "a", "name": "x"}


def test_serialize_list_shape_and_has_more():
    pl = FakePaginatedList([FakeModel({"id": "1"}), FakeModel({"id": "2"})], total=10)
    out = serialize_list(pl, offset=0)
    assert out["items"] == [{"id": "1"}, {"id": "2"}]
    assert out["count"] == 2
    assert out["total"] == 10
    assert out["offset"] == 0
    assert out["has_more"] is True


def test_serialize_list_has_more_false_at_end():
    pl = FakePaginatedList([FakeModel({"id": "9"})], total=10)
    out = serialize_list(pl, offset=9)
    assert out["has_more"] is False


def test_serialize_dataframe_windows_rows():
    df = pd.DataFrame({"a": range(1000), "b": range(1000)})
    out = serialize_dataframe(df, limit=10, offset=0)
    assert out["row_count"] == 10
    assert out["total_rows"] == 1000
    assert out["truncated"] is True
    assert out["rows"][0] == {"a": 0, "b": 0}
    assert set(out["columns"]) == {"a", "b"}
    assert "a" in out["dtypes"]


def test_serialize_dataframe_offset_paging():
    df = pd.DataFrame({"a": range(100)})
    out = serialize_dataframe(df, limit=5, offset=50)
    assert out["rows"][0]["a"] == 50
    assert out["offset"] == 50


def test_serialize_dataframe_column_projection():
    df = pd.DataFrame({"a": [1, 2], "b": [3, 4], "c": [5, 6]})
    out = serialize_dataframe(df, columns=["a", "c"])
    assert set(out["columns"]) == {"a", "c"}
    assert "b" not in out["rows"][0]


def test_serialize_dataframe_unknown_column_raises():
    df = pd.DataFrame({"a": [1]})
    try:
        serialize_dataframe(df, columns=["a", "nope"])
    except ValueError as exc:
        assert "nope" in str(exc)
    else:
        raise AssertionError("expected ValueError for unknown column")


def test_serialize_dataframe_limit_clamped_to_max():
    df = pd.DataFrame({"a": range(MAX_ROW_LIMIT + 100)})
    out = serialize_dataframe(df, limit=MAX_ROW_LIMIT + 50)
    assert out["row_count"] == MAX_ROW_LIMIT


def test_serialize_dataframe_not_truncated_when_all_returned():
    df = pd.DataFrame({"a": [1, 2, 3]})
    out = serialize_dataframe(df, limit=DEFAULT_ROW_LIMIT, offset=0)
    assert out["truncated"] is False
    assert out["row_count"] == 3
```

* [ ] **Step 2: Run to verify failure**

Run: `just test-mcp tests/test_serialize.py -v`
Expected: FAIL (module missing).

* [ ] **Step 3: Implement `serialize.py`**

```python theme={null}
"""Normalize SDK return values to JSON-safe dicts for MCP responses."""

from __future__ import annotations

from typing import Any

import pandas as pd

DEFAULT_ROW_LIMIT = 500
MAX_ROW_LIMIT = 5000


def serialize_model(model: Any) -> dict:
    """Serialize a single Pydantic v2 model to a JSON-safe dict."""
    return model.model_dump(mode="json")


def serialize_list(paginated: Any, offset: int = 0) -> dict:
    """Serialize a PaginatedList (list + ``.total``) to a paged response.

    ``PaginatedList`` has no offset of its own, so ``offset`` is threaded in
    from the calling tool. ``has_more`` is ``offset + count < total``.
    """
    items = [serialize_model(item) for item in paginated]
    count = len(items)
    total = int(getattr(paginated, "total", count) or 0)
    return {
        "items": items,
        "count": count,
        "total": total,
        "offset": offset,
        "has_more": offset + count < total,
    }


def serialize_dataframe(
    df: pd.DataFrame,
    columns: list[str] | None = None,
    limit: int = DEFAULT_ROW_LIMIT,
    offset: int = 0,
    units: dict[str, str] | None = None,
) -> dict:
    """Serialize a (pandas) DataFrame to a windowed, JSON-safe response.

    Row count is bounded by ``limit`` (clamped to ``MAX_ROW_LIMIT``); ``offset``
    pages through rows; ``columns`` projects a subset (unknown names raise
    ``ValueError``). ``total_rows`` and ``dtypes`` always reflect the full frame
    so the caller knows the shape even when ``truncated`` is True.
    """
    if columns is not None:
        missing = [c for c in columns if c not in df.columns]
        if missing:
            raise ValueError(
                f"Unknown column(s): {', '.join(missing)}. "
                f"Available: {', '.join(map(str, df.columns))}"
            )
        df = df[columns]

    total_rows = int(len(df))
    effective_limit = min(max(int(limit), 0), MAX_ROW_LIMIT)
    window = df.iloc[offset : offset + effective_limit]
    rows = window.to_dict(orient="records")
    row_count = len(rows)

    response: dict = {
        "columns": [str(c) for c in df.columns],
        "dtypes": {str(c): str(dt) for c, dt in df.dtypes.items()},
        "rows": rows,
        "row_count": row_count,
        "total_rows": total_rows,
        "offset": offset,
        "truncated": offset + row_count < total_rows,
        "note": (
            f"Returned {row_count} of {total_rows} rows"
            f" (limit {effective_limit}, offset {offset})."
            " Use offset to page, or columns=[...] to narrow."
        ),
    }
    if units is not None:
        response["units"] = units
    return response
```

* [ ] **Step 4: Run to verify pass**

Run: `just test-mcp tests/test_serialize.py -v`
Expected: PASS (all serialize tests).

* [ ] **Step 5: Commit**

```bash theme={null}
git add packages/ionworks-mcp/ionworks_mcp/serialize.py packages/ionworks-mcp/tests/test_serialize.py
git commit -m "feat(ionworks-mcp): JSON serialization with DataFrame row-windowing"
```

***

## Task 5: Discovery + cell tools (`tools/discovery.py`, `tools/cells.py`)

**Files:**

* Create: `packages/ionworks-mcp/ionworks_mcp/tools/__init__.py`
* Create: `packages/ionworks-mcp/ionworks_mcp/tools/discovery.py`
* Create: `packages/ionworks-mcp/ionworks_mcp/tools/cells.py`
* Test: append to `packages/ionworks-mcp/tests/test_tools.py`

**Pattern note:** Each `tools/*.py` exposes a `register(mcp)` function that defines `@mcp.tool()`-decorated inner functions, each also wrapped with `@tool_errors`. Tool bodies call `get_client()` (imported from `ionworks_mcp.client`) so the conftest monkeypatch of `client_mod.get_client` takes effect. Import as `from ionworks_mcp import client as client_mod` and call `client_mod.get_client()` inside each tool — this ensures the patched reference is used.

* [ ] **Step 1: Write failing tests** appended to `tests/test_tools.py`

```python theme={null}
from mcp.server.fastmcp import FastMCP

from ionworks_mcp.serialize import serialize_list, serialize_model
from ionworks_mcp.tools import cells, discovery
from tests._fakes import FakeModel, FakePaginatedList


def _tool_fns(register, fake, monkeypatch):
    """Register a module's tools on a throwaway FastMCP and return {name: fn}.

    Uses the tool-enumeration accessor confirmed in Task 1 Step 6
    (``mcp._tool_manager.list_tools()`` with ``.name``/``.fn``); if Task 1
    recorded a different accessor for the resolved ``mcp`` version, use that
    form here instead.
    """
    import ionworks_mcp.client as client_mod

    monkeypatch.setattr(client_mod, "get_client", lambda: fake)
    mcp = FastMCP("test")
    register(mcp)
    return {t.name: t.fn for t in mcp._tool_manager.list_tools()}


def test_discover_capabilities(fake_client, monkeypatch):
    fns = _tool_fns(discovery.register, fake_client, monkeypatch)
    out = fns["discover_capabilities"]()
    assert out["name"] == "Ionworks"


def test_list_cell_specs_delegates_and_serializes(fake_client, monkeypatch):
    captured = {}

    def fake_list(include_components=False, limit=None, offset=None, **kw):
        captured.update(dict(include_components=include_components, limit=limit, offset=offset, **kw))
        return FakePaginatedList([FakeModel({"id": "spec-1"})], total=1)

    fake_client.cell_spec.list = fake_list
    fns = _tool_fns(cells.register, fake_client, monkeypatch)

    out = fns["list_cell_specs"](name="foo", limit=10, offset=0)
    assert out["items"] == [{"id": "spec-1"}]
    assert out["total"] == 1
    assert captured["name"] == "foo"
    assert captured["limit"] == 10


def test_get_cell_spec_delegates(fake_client, monkeypatch):
    # No **kw: asserts the tool calls get() with exactly the SDK signature
    # (cell_spec_id only). If the tool passed include_components, this raises.
    fake_client.cell_spec.get = lambda cell_spec_id: FakeModel({"id": cell_spec_id})
    fns = _tool_fns(cells.register, fake_client, monkeypatch)
    out = fns["get_cell_spec"](cell_spec_id="spec-9")
    assert out == {"id": "spec-9"}


def test_list_cell_instances_passes_required_id(fake_client, monkeypatch):
    captured = {}

    def fake_list(cell_spec_id, limit=None, offset=None, **kw):
        captured["cell_spec_id"] = cell_spec_id
        return FakePaginatedList([], total=0)

    fake_client.cell_instance.list = fake_list
    fns = _tool_fns(cells.register, fake_client, monkeypatch)
    fns["list_cell_instances"](cell_spec_id="spec-1")
    assert captured["cell_spec_id"] == "spec-1"


def test_list_measurements_error_wrapped(fake_client, monkeypatch):
    from ionworks import IonworksError

    def boom(cell_instance_id, **kw):
        raise IonworksError("boom")

    fake_client.cell_measurement.list = boom
    fns = _tool_fns(cells.register, fake_client, monkeypatch)
    out = fns["list_measurements"](cell_instance_id="ci-1")
    assert out["error"]["code"] == "IonworksError"
```

* [ ] **Step 2: Run to verify failure**

Run: `just test-mcp tests/test_tools.py -k "discover or cell or measurement" -v`
Expected: FAIL (tools modules missing).

* [ ] **Step 3: Implement `tools/discovery.py`**

```python theme={null}
"""Discovery tool: platform capabilities and domain context."""

from __future__ import annotations

from ionworks_mcp import client as client_mod
from ionworks_mcp.errors import tool_errors


def register(mcp) -> None:
    @mcp.tool()
    @tool_errors
    def discover_capabilities() -> dict:
        """Return Ionworks platform capabilities, data hierarchy, and key concepts.

        Call this first to understand the entity hierarchy (cell specification →
        instance → measurement → time-series/steps/cycles) and available schemas.
        """
        return client_mod.get_client().capabilities()
```

* [ ] **Step 4: Implement `tools/cells.py`**

```python theme={null}
"""Cell-data hierarchy tools: specifications, instances, measurements."""

from __future__ import annotations

from ionworks_mcp import client as client_mod
from ionworks_mcp.errors import tool_errors
from ionworks_mcp.serialize import serialize_list, serialize_model


def register(mcp) -> None:
    @mcp.tool()
    @tool_errors
    def list_cell_specs(
        name: str | None = None,
        form_factor: str | None = None,
        include_components: bool = False,
        limit: int = 25,
        offset: int = 0,
    ) -> dict:
        """List cell specifications (blueprints: chemistry, form factor, ratings)."""
        result = client_mod.get_client().cell_spec.list(
            include_components=include_components,
            limit=limit,
            offset=offset,
            name=name,
            form_factor=form_factor,
        )
        return serialize_list(result, offset=offset)

    @mcp.tool()
    @tool_errors
    def get_cell_spec(cell_spec_id: str) -> dict:
        """Get one cell specification by id.

        (The SDK's ``cell_spec.get`` returns the full spec; there is no
        ``include_components`` toggle at the get level — use ``list_cell_specs``
        with ``include_components=True`` for nested component/material data.)
        """
        return serialize_model(client_mod.get_client().cell_spec.get(cell_spec_id))

    @mcp.tool()
    @tool_errors
    def list_cell_instances(
        cell_spec_id: str,
        name: str | None = None,
        limit: int = 25,
        offset: int = 0,
    ) -> dict:
        """List physical cell instances built from a given specification."""
        result = client_mod.get_client().cell_instance.list(
            cell_spec_id, limit=limit, offset=offset, name=name
        )
        return serialize_list(result, offset=offset)

    @mcp.tool()
    @tool_errors
    def get_cell_instance(cell_instance_id: str) -> dict:
        """Get one physical cell instance by id."""
        return serialize_model(
            client_mod.get_client().cell_instance.get(cell_instance_id)
        )

    @mcp.tool()
    @tool_errors
    def list_measurements(
        cell_instance_id: str,
        name: str | None = None,
        measurement_type: str | None = None,
        limit: int = 25,
        offset: int = 0,
    ) -> dict:
        """List measurements (tests/experiments) for a cell instance."""
        result = client_mod.get_client().cell_measurement.list(
            cell_instance_id,
            limit=limit,
            offset=offset,
            measurement_type=measurement_type,
            name=name,
        )
        return serialize_list(result, offset=offset)

    @mcp.tool()
    @tool_errors
    def get_measurement(measurement_id: str) -> dict:
        """Get one measurement's metadata by id."""
        return serialize_model(
            client_mod.get_client().cell_measurement.get(measurement_id)
        )
```

* [ ] **Step 5: Implement `tools/__init__.py`** (register discovery + cells now; the other two modules are appended in later tasks)

```python theme={null}
"""Tool registration: wire every tool module onto the FastMCP instance."""

from __future__ import annotations

from ionworks_mcp.tools import cells, discovery, materials, measurement_data


def register_all(mcp) -> None:
    discovery.register(mcp)
    cells.register(mcp)
    measurement_data.register(mcp)
    materials.register(mcp)
```

Note: `register_all` imports `materials` and `measurement_data` which are created in Tasks 6-7. If running Task 5 in isolation, temporarily import only `cells, discovery` and expand in Task 8; the reviewer/executor should create all four modules before importing `tools/__init__.py`. Prefer creating empty `measurement_data.py` / `materials.py` stubs (each with a no-op `register`) in this step to keep imports valid, then fill them in Tasks 6-7.

* [ ] **Step 6: Create stub `tools/measurement_data.py` and `tools/materials.py`**

```python theme={null}
"""Stub — filled in a later task."""

def register(mcp) -> None:  # noqa: D401
    pass
```

(Identical stub content in both files.)

* [ ] **Step 7: Run to verify pass**

Run: `just test-mcp tests/test_tools.py -k "discover or cell or measurement" -v`
Expected: PASS.

* [ ] **Step 8: Commit**

```bash theme={null}
git add packages/ionworks-mcp/ionworks_mcp/tools/ packages/ionworks-mcp/tests/test_tools.py
git commit -m "feat(ionworks-mcp): discovery + cell hierarchy tools"
```

***

## Task 6: Measurement-data tools (`tools/measurement_data.py`)

**Files:**

* Modify: `packages/ionworks-mcp/ionworks_mcp/tools/measurement_data.py` (replace stub)

* Test: append to `packages/ionworks-mcp/tests/test_tools.py`

* [ ] **Step 1: Write failing tests** appended to `tests/test_tools.py`

```python theme={null}
from ionworks_mcp.tools import measurement_data


def test_time_series_windows_and_projects(fake_client, monkeypatch, sample_df):
    fake_client.cell_measurement.time_series = lambda mid, **kw: sample_df
    fns = _tool_fns(measurement_data.register, fake_client, monkeypatch)
    out = fns["get_measurement_time_series"](
        measurement_id="m-1", columns=["Time [s]", "Voltage [V]"], limit=2, offset=0
    )
    assert out["total_rows"] == 3
    assert out["row_count"] == 2
    assert out["truncated"] is True
    assert set(out["columns"]) == {"Time [s]", "Voltage [V]"}


def test_steps_delegates(fake_client, monkeypatch, sample_df):
    fake_client.cell_measurement.steps = lambda mid, **kw: sample_df
    fns = _tool_fns(measurement_data.register, fake_client, monkeypatch)
    out = fns["get_measurement_steps"](measurement_id="m-1")
    assert out["total_rows"] == 3


def test_cycles_delegates(fake_client, monkeypatch, sample_df):
    fake_client.cell_measurement.cycles = lambda mid, **kw: sample_df
    fns = _tool_fns(measurement_data.register, fake_client, monkeypatch)
    out = fns["get_measurement_cycles"](measurement_id="m-1")
    assert out["total_rows"] == 3


def test_time_series_unknown_column_is_bad_request(fake_client, monkeypatch, sample_df):
    fake_client.cell_measurement.time_series = lambda mid, **kw: sample_df
    fns = _tool_fns(measurement_data.register, fake_client, monkeypatch)
    out = fns["get_measurement_time_series"](measurement_id="m-1", columns=["nope"])
    assert out["error"]["code"] == "BadRequest"
```

* [ ] **Step 2: Run to verify failure**

Run: `just test-mcp tests/test_tools.py -k "time_series or steps or cycles" -v`
Expected: FAIL (stub returns nothing / no tools registered).

* [ ] **Step 3: Implement `tools/measurement_data.py`** (replace stub)

```python theme={null}
"""Measurement time-series / steps / cycles tools (row-windowed)."""

from __future__ import annotations

from ionworks_mcp import client as client_mod
from ionworks_mcp.errors import tool_errors
from ionworks_mcp.serialize import DEFAULT_ROW_LIMIT, serialize_dataframe


def register(mcp) -> None:
    @mcp.tool()
    @tool_errors
    def get_measurement_time_series(
        measurement_id: str,
        columns: list[str] | None = None,
        limit: int = DEFAULT_ROW_LIMIT,
        offset: int = 0,
    ) -> dict:
        """Get measurement time-series data (row-windowed; use columns/offset to narrow)."""
        df = client_mod.get_client().cell_measurement.time_series(measurement_id)
        return serialize_dataframe(df, columns=columns, limit=limit, offset=offset)

    @mcp.tool()
    @tool_errors
    def get_measurement_steps(
        measurement_id: str,
        columns: list[str] | None = None,
        limit: int = DEFAULT_ROW_LIMIT,
        offset: int = 0,
    ) -> dict:
        """Get per-step summary data for a measurement (row-windowed)."""
        df = client_mod.get_client().cell_measurement.steps(measurement_id)
        return serialize_dataframe(df, columns=columns, limit=limit, offset=offset)

    @mcp.tool()
    @tool_errors
    def get_measurement_cycles(
        measurement_id: str,
        columns: list[str] | None = None,
        limit: int = DEFAULT_ROW_LIMIT,
        offset: int = 0,
    ) -> dict:
        """Get per-cycle metrics for a measurement (row-windowed)."""
        df = client_mod.get_client().cell_measurement.cycles(measurement_id)
        return serialize_dataframe(df, columns=columns, limit=limit, offset=offset)
```

* [ ] **Step 4: Run to verify pass**

Run: `just test-mcp tests/test_tools.py -k "time_series or steps or cycles" -v`
Expected: PASS.

* [ ] **Step 5: Commit**

```bash theme={null}
git add packages/ionworks-mcp/ionworks_mcp/tools/measurement_data.py packages/ionworks-mcp/tests/test_tools.py
git commit -m "feat(ionworks-mcp): measurement time-series/steps/cycles tools"
```

***

## Task 7: Materials tools (`tools/materials.py`)

**Files:**

* Modify: `packages/ionworks-mcp/ionworks_mcp/tools/materials.py` (replace stub)

* Test: append to `packages/ionworks-mcp/tests/test_tools.py`

* [ ] **Step 1: Write failing tests** appended to `tests/test_tools.py`

```python theme={null}
from ionworks_mcp.tools import materials


def test_list_materials_delegates(fake_client, monkeypatch):
    fake_client.material.list = lambda limit=None, offset=None, project_id=None: FakePaginatedList(
        [FakeModel({"id": "mat-1"})], total=1
    )
    fns = _tool_fns(materials.register, fake_client, monkeypatch)
    out = fns["list_materials"]()
    assert out["items"] == [{"id": "mat-1"}]


def test_get_material_delegates(fake_client, monkeypatch):
    fake_client.material.get = lambda material_id: FakeModel({"id": material_id})
    fns = _tool_fns(materials.register, fake_client, monkeypatch)
    assert fns["get_material"](material_id="mat-9") == {"id": "mat-9"}


def test_list_property_datasets_requires_material_id(fake_client, monkeypatch):
    captured = {}
    fake_client.material_property_dataset.list = (
        lambda material_id, project_id=None, limit=None, offset=None: captured.update(
            material_id=material_id
        )
        or FakePaginatedList([], total=0)
    )
    fns = _tool_fns(materials.register, fake_client, monkeypatch)
    fns["list_material_property_datasets"](material_id="mat-1")
    assert captured["material_id"] == "mat-1"


def test_get_property_data_merges_units(fake_client, monkeypatch, sample_df):
    fake_client.material_property_dataset.get_data = lambda dataset_id: sample_df
    fake_client.material_property_dataset.get_units = lambda dataset_id: {"Voltage [V]": "V"}
    fns = _tool_fns(materials.register, fake_client, monkeypatch)
    out = fns["get_material_property_data"](dataset_id="d-1")
    assert out["units"] == {"Voltage [V]": "V"}
    assert out["total_rows"] == 3
```

* [ ] **Step 2: Run to verify failure**

Run: `just test-mcp tests/test_tools.py -k "material" -v`
Expected: FAIL.

* [ ] **Step 3: Implement `tools/materials.py`** (replace stub)

```python theme={null}
"""Materials and material-property-dataset tools."""

from __future__ import annotations

from ionworks_mcp import client as client_mod
from ionworks_mcp.errors import tool_errors
from ionworks_mcp.serialize import (
    DEFAULT_ROW_LIMIT,
    serialize_dataframe,
    serialize_list,
    serialize_model,
)


def register(mcp) -> None:
    @mcp.tool()
    @tool_errors
    def list_materials(
        project_id: str | None = None, limit: int = 25, offset: int = 0
    ) -> dict:
        """List materials for the organization (optionally scope counts to a project)."""
        result = client_mod.get_client().material.list(
            limit=limit, offset=offset, project_id=project_id
        )
        return serialize_list(result, offset=offset)

    @mcp.tool()
    @tool_errors
    def get_material(material_id: str) -> dict:
        """Get one material by id."""
        return serialize_model(client_mod.get_client().material.get(material_id))

    @mcp.tool()
    @tool_errors
    def list_material_property_datasets(
        material_id: str,
        project_id: str | None = None,
        limit: int = 25,
        offset: int = 0,
    ) -> dict:
        """List property datasets for a given material."""
        result = client_mod.get_client().material_property_dataset.list(
            material_id, project_id=project_id, limit=limit, offset=offset
        )
        return serialize_list(result, offset=offset)

    @mcp.tool()
    @tool_errors
    def get_material_property_dataset(dataset_id: str) -> dict:
        """Get one material property dataset's metadata by id."""
        return serialize_model(
            client_mod.get_client().material_property_dataset.get(dataset_id)
        )

    @mcp.tool()
    @tool_errors
    def get_material_property_data(
        dataset_id: str,
        columns: list[str] | None = None,
        limit: int = DEFAULT_ROW_LIMIT,
        offset: int = 0,
    ) -> dict:
        """Get a property dataset's tabular data (row-windowed) with column units."""
        client = client_mod.get_client()
        df = client.material_property_dataset.get_data(dataset_id)
        units = client.material_property_dataset.get_units(dataset_id)
        return serialize_dataframe(
            df, columns=columns, limit=limit, offset=offset, units=units
        )
```

* [ ] **Step 4: Run to verify pass**

Run: `just test-mcp tests/test_tools.py -k "material" -v`
Expected: PASS.

* [ ] **Step 5: Commit**

```bash theme={null}
git add packages/ionworks-mcp/ionworks_mcp/tools/materials.py packages/ionworks-mcp/tests/test_tools.py
git commit -m "feat(ionworks-mcp): materials + property-dataset tools"
```

***

## Task 8: Server + entry point + all-tools-registered test

**Files:**

* Create: `packages/ionworks-mcp/ionworks_mcp/server.py`

* Create: `packages/ionworks-mcp/ionworks_mcp/__main__.py`

* Test: `packages/ionworks-mcp/tests/test_server.py`

* [ ] **Step 1: Write the failing test** in `tests/test_server.py`

```python theme={null}
from ionworks_mcp.server import build_server

EXPECTED_TOOLS = {
    "discover_capabilities",
    "list_cell_specs",
    "get_cell_spec",
    "list_cell_instances",
    "get_cell_instance",
    "list_measurements",
    "get_measurement",
    "get_measurement_time_series",
    "get_measurement_steps",
    "get_measurement_cycles",
    "list_materials",
    "get_material",
    "list_material_property_datasets",
    "get_material_property_dataset",
    "get_material_property_data",
}


def test_all_fifteen_tools_registered():
    mcp = build_server()
    names = {t.name for t in mcp._tool_manager.list_tools()}
    assert names == EXPECTED_TOOLS
    assert len(names) == 15


def test_tool_schema_exposes_real_params():
    # Guards against @tool_errors erasing annotations, which would make every
    # tool advertise (*args, **kwargs) and silently break MCP schema discovery.
    mcp = build_server()
    tools = {t.name: t for t in mcp._tool_manager.list_tools()}
    schema = tools["list_cell_specs"].parameters  # JSON Schema dict
    props = set(schema.get("properties", {}))
    assert {"name", "form_factor", "include_components", "limit", "offset"} <= props
```

Note: the attribute holding the JSON Schema (`.parameters` above) is the one confirmed in Task 1 Step 6. If that probe showed a different attribute name (e.g. `.inputSchema`), use the confirmed name here.

* [ ] **Step 2: Run to verify failure**

Run: `just test-mcp tests/test_server.py -v`
Expected: FAIL (`server` module missing).

* [ ] **Step 3: Implement `server.py`**

```python theme={null}
"""FastMCP server assembly and stdio entry point."""

from __future__ import annotations

from mcp.server.fastmcp import FastMCP

from ionworks_mcp.tools import register_all


def build_server() -> FastMCP:
    """Construct the FastMCP server with all read-only Ionworks tools registered."""
    mcp = FastMCP("ionworks")
    register_all(mcp)
    return mcp


def run() -> None:
    """Run the server over stdio (blocking)."""
    build_server().run()
```

* [ ] **Step 4: Implement `__main__.py`**

```python theme={null}
"""Console entry point: ``ionworks-mcp`` / ``python -m ionworks_mcp``."""

from __future__ import annotations

from ionworks_mcp.server import run


def main() -> None:
    run()


if __name__ == "__main__":
    main()
```

* [ ] **Step 5: Run to verify pass**

Run: `just test-mcp tests/test_server.py -v`
Expected: PASS (exactly 15 tools).

* [ ] **Step 6: Run the full package suite**

Run: `just test-mcp`
Expected: PASS (all tests across test\_serialize / test\_errors / test\_tools / test\_server).

* [ ] **Step 7: Commit**

```bash theme={null}
git add packages/ionworks-mcp/ionworks_mcp/server.py packages/ionworks-mcp/ionworks_mcp/__main__.py packages/ionworks-mcp/tests/test_server.py
git commit -m "feat(ionworks-mcp): FastMCP server assembly + stdio entry point"
```

***

## Task 9: README + manual smoke test

**Files:**

* Create: `packages/ionworks-mcp/README.md`

* [ ] **Step 1: Write `README.md`**

Cover: what it is (read-only MCP server over the Ionworks public API), install (`uv sync --all-packages` in-repo / `pip install ionworks-mcp` future), required env (`IONWORKS_API_KEY`, optional `IONWORKS_API_URL`), an example MCP client config block launching `ionworks-mcp` over stdio, the 15 tools grouped (cells / measurement-data / materials / discovery), the DataFrame windowing behaviour (default 500 / max 5000 rows, `columns`/`offset` paging), and the explicit v1 exclusions (no writes; no projects/studies/models/simulations; no ECM/optimization/protocol) with a pointer to Future work in the spec.

* [ ] **Step 2: Manual smoke test (documented, not automated)**

With a real key exported, run:

```bash theme={null}
IONWORKS_API_KEY=... uv run --package ionworks-mcp python -c "from ionworks_mcp.server import build_server; m=build_server(); print(sorted(t.name for t in m._tool_manager.list_tools()))"
```

Expected: prints the 15 tool names. (Optional deeper check: run `ionworks-mcp` and connect from an MCP client.)

* [ ] **Step 3: Commit**

```bash theme={null}
git add packages/ionworks-mcp/README.md
git commit -m "docs(ionworks-mcp): usage, MCP client config, and tool reference"
```

***

## Task 10: Full verification pass

* [ ] **Step 1: Run the whole package suite once more**

Run: `just test-mcp -v`
Expected: all green.

* [ ] **Step 2: Confirm the workspace still resolves and other packages are unaffected**

Run: `uv sync --all-packages`
Expected: no errors.

* [ ] **Step 3: Run prek / pre-commit on the changed files** (use the `prek` skill if available)

Expected: hooks pass (ruff, formatting). Fix any lint issues (e.g. unused imports in tests) and amend the relevant commit.

* [ ] **Step 4: Final review of `git log --oneline` for a clean, story-telling history.**

***

## Notes for the implementer

* **FastMCP internals:** the test helper reads `mcp._tool_manager.list_tools()` and each tool's `.name` / `.fn` / `.parameters`. These are pinned by **Task 1 Step 6** — that probe is authoritative; if it recorded a different accessor for the resolved `mcp` version, use that form in `_tool_fns`, `test_server.py`, and the Task 9 smoke test. The intent is "enumerate registered tools, their callables, and their JSON schema".
* **`@tool_errors` under `@mcp.tool()`:** the decorators stack so that `mcp.tool()` registers the already-error-wrapped function. `functools.wraps` preserves the signature/annotations FastMCP introspects for the tool schema. This is enforced by `test_tool_schema_exposes_real_params` (Task 8) — if that test fails (FastMCP lost annotations), move error handling inside each function body instead of as an outer decorator.
* **DRY:** all four tool modules share the `client_mod.get_client()` + `serialize_*` pattern — do not duplicate serialization logic into the tools.
* **YAGNI:** do not add tools, filters, or write paths beyond the 15 listed. Deferred items live in the spec's Future work.
