> ## 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 design

# Ionworks MCP Read Layer — Design

**Date:** 2026-07-08
**Status:** Approved (pending spec review + user review)
**Author:** Valentin (with Claude)

## Summary

A lightweight, read-only MCP (Model Context Protocol) server that exposes the
Ionworks **public API** — the endpoints marked `openapi_extra={"x-hidden":
False}` in the FastAPI backend — as MCP tools, so an MCP client (Claude
Desktop, Claude Code, etc.) can browse Ionworks battery **data and metadata**.

v1 is deliberately scoped to the **cell-data hierarchy** (cell specification →
cell instance → measurement → time-series/steps/cycles) plus **materials**. It
is read-only. Writes, and the entity-management surface (projects, studies,
models, parameterized models, simulations), are explicitly out of scope for v1.

The v1 surface is **15 tools**.

## Goals

* Expose full cell **data and metadata** (including time-series, steps, cycles)
  and material data through MCP tools.
* Reuse the existing `ionworks-api` Python SDK for all HTTP, auth, retries,
  gzip, caching, pagination, and response modelling — the MCP layer is thin
  wrappers, no new transport code.
* Keep it lightweight: one small workspace package, 15 tools, unit-tested with
  no network dependency.

## Non-goals (v1)

* Any write/mutation operation (create/update/delete/upload).
* Projects, studies, models, parameterized models, simulations (entity
  browsing) — cut per user direction to focus on data + materials.
* ECM / optimization / protocol / electrolyte reads.
* A remotely-hosted / multi-tenant MCP endpoint (see "Future work").

## Decision: build on the SDK, not the backend

The MCP server is a **local process the user runs**; their MCP client launches
it over stdio. It authenticates with the user's own `IONWORKS_API_KEY` from the
environment, exactly as the SDK does.

Rationale vs. a backend-hosted (`fastapi-mcp`) endpoint:

* The SDK path requires **no new auth or hosting**. A hosted endpoint would mean
  building remote MCP auth (per-request key → org resolution over the MCP
  transport), SSE/HTTP infra, CORS, and rate limiting — all net-new and the bulk
  of the work. That is the wrong shape for a "lightweight, read-only start".
* The SDK already encodes filter/pagination logic and can compose multiple calls
  into one ergonomic method; wrapping routes directly would re-derive that.
* Tools are thin SDK wrappers, so if a hosted version is built later, most tool
  code moves over largely unchanged.

`packages/ionworks-mcp` is **not** one of the four publicly-mirrored packages
(`iwutil`, `ionworksdata`, `ionworks-schema`, `ionworks-api`). It depends only
on `ionworks-api` (which is public). No confidentiality concern.

## Architecture

MCP client (Claude Desktop/Code) launches `ionworks-mcp` as a **stdio**
subprocess. FastMCP dispatches a tool call → the tool function calls
`get_client().<subclient>.<method>(...)` → `serialize()` normalizes the result
to a JSON-safe dict → JSON returned over stdio. The `Ionworks` client is
constructed **once, lazily**, reading `IONWORKS_API_KEY` (and optional
`IONWORKS_API_URL` / project id) from the environment. `get_client()` also
**forces the pandas DataFrame backend** (`set_dataframe_backend("pandas")`)
before constructing the client, so `serialize.py` can rely on a single, stable
DataFrame API. The SDK otherwise defaults to polars
(`IONWORKS_DATAFRAME_BACKEND`, default `"polars"`,
`packages/ionworks-api/ionworks/validators.py`), whose row/column/dtype API
differs; forcing pandas removes that ambiguity for the serializer.

Framework: the official Python `mcp` SDK's **FastMCP**, decorator-based tools
(`@mcp.tool`), **stdio** transport.

### Package layout

```
packages/ionworks-mcp/
├── pyproject.toml            # deps: ionworks-api (workspace), mcp>=1.x
│                             # entry point: ionworks-mcp = ionworks_mcp.__main__:main
├── README.md
├── ionworks_mcp/
│   ├── __init__.py
│   ├── __main__.py           # python -m ionworks_mcp / console entry point
│   ├── server.py             # builds FastMCP instance, registers tools, runs stdio
│   ├── client.py             # lazy singleton: get_client() -> Ionworks() from env
│   ├── serialize.py          # PaginatedList/BaseModel/DataFrame -> JSON-safe dict
│   ├── errors.py             # @tool_errors decorator -> structured error payloads
│   └── tools/                # 15 tools total across the modules below
│       ├── __init__.py       # register_all(mcp)
│       ├── discovery.py      # discover_capabilities
│       ├── cells.py          # cell specs, instances, measurements (list/get)
│       ├── measurement_data.py  # time_series / steps / cycles (windowed)
│       └── materials.py      # materials + material property datasets + data
└── tests/
    ├── conftest.py           # fake Ionworks client fixture
    ├── test_tools.py         # per-tool: delegation, serialization, errors
    └── test_server.py        # all 15 tools register on the FastMCP instance
```

## Tool surface (15 tools, read-only)

Required arguments are marked `(req)`; everything else is optional. `limit` /
`offset` default per the "DataFrame → JSON" and serialization sections.

### Cell data hierarchy

| Tool                          | SDK call                              | Key args                                                                                                                              |
| ----------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `discover_capabilities`       | `client.capabilities()`               | —                                                                                                                                     |
| `list_cell_specs`             | `client.cell_spec.list`               | `name`, `form_factor`, `include_components`, `limit`, `offset`                                                                        |
| `get_cell_spec`               | `client.cell_spec.get`                | `cell_spec_id` (req) — the SDK `get` has no `include_components`; use `list_cell_specs(include_components=True)` for nested materials |
| `list_cell_instances`         | `client.cell_instance.list`           | `cell_spec_id` (req), `name`, `limit`, `offset`                                                                                       |
| `get_cell_instance`           | `client.cell_instance.get`            | `cell_instance_id` (req)                                                                                                              |
| `list_measurements`           | `client.cell_measurement.list`        | `cell_instance_id` (req), `name`, `measurement_type`, `limit`, `offset`                                                               |
| `get_measurement`             | `client.cell_measurement.get`         | `measurement_id` (req)                                                                                                                |
| `get_measurement_time_series` | `client.cell_measurement.time_series` | `measurement_id` (req), `columns?`, `limit`, `offset`                                                                                 |
| `get_measurement_steps`       | `client.cell_measurement.steps`       | `measurement_id` (req), `limit`, `offset`                                                                                             |
| `get_measurement_cycles`      | `client.cell_measurement.cycles`      | `measurement_id` (req), `limit`, `offset`                                                                                             |

### Materials

| Tool                              | SDK call                                                    | Key args                                              |
| --------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- |
| `list_materials`                  | `client.material.list`                                      | `project_id?`, `limit`, `offset`                      |
| `get_material`                    | `client.material.get`                                       | `material_id` (req)                                   |
| `list_material_property_datasets` | `client.material_property_dataset.list`                     | `material_id` (req), `project_id?`, `limit`, `offset` |
| `get_material_property_dataset`   | `client.material_property_dataset.get`                      | `dataset_id` (req)                                    |
| `get_material_property_data`      | `client.material_property_dataset.get_data` (+ `get_units`) | `dataset_id` (req), `columns?`, `limit`, `offset`     |

**`discover_capabilities`** is the one tool that wraps a **client-level** method
(`client.capabilities()` → `GET /discovery/capabilities`,
`packages/ionworks-api/ionworks/client.py`) rather than a sub-client method. Its
result is a plain dict (hierarchy, key concepts, auth info, schema pointers) and
is returned as-is. The related `client.schema(name)` / `client.pybamm_models()`
are **not** exposed in v1 (deferred to Future work).

Required-vs-optional is taken from the real SDK signatures:
`cell_instance.list(cell_spec_id, ...)`, `cell_measurement.list(cell_instance_id,
...)`, and `material_property_dataset.list(material_id, ...)` all take their
scoping id as a **required positional** first argument. Tools mark these required
in their MCP schemas; a missing required arg is surfaced as a structured error
before any SDK call. Exact signatures are re-verified against the SDK at
implementation time rather than invented.

## DataFrame → JSON (the key design point)

`time_series` / `steps` / `cycles` / `get_data` return `DataFrame`s that can be
very large (time-series easily 100k+ rows). Returning them verbatim would blow
the context window and MCP message size. Therefore the data tools apply
**mandatory server-side windowing**:

* Default `limit = 500` rows, hard cap `max = 5000` rows. A requested limit above
  the cap is clamped (and the response notes it).
* Optional `columns` projects to only the requested signals. Projection is done
  **client-side after download** — `time_series()` downloads the full parquet
  (and the SDK caches it), so repeated row/column slices are cheap; only the
  *response* is bounded, not the fetch.
* Response shape:
  ```json theme={null}
  {
    "columns": ["Time [s]", "Voltage [V]", ...],
    "dtypes": {"Time [s]": "float64", ...},
    "rows": [{"Time [s]": 0.0, "Voltage [V]": 3.7, ...}, ...],
    "row_count": 500,
    "total_rows": 132004,
    "offset": 0,
    "truncated": true,
    "note": "Returned 500 of 132004 rows. Use offset to page, or columns=[...] to narrow."
  }
  ```
* Row/column counts and dtypes are always returned even when truncated, so the
  model knows the full shape.
* For `get_material_property_data`, units from `get_units(dataset_id)` are merged
  into the response (e.g. a `units` map alongside `columns`).

## Serialization (`serialize.py`)

* `PaginatedList` / `list[BaseModel]` → `{"items": [...], "count": n, "total": t,
  "offset": o, "has_more": bool}`. `PaginatedList` exposes only `.items`,
  `.count`, and `.total` (`packages/ionworks-api/ionworks/models.py`) — it has
  **no** `offset`. The serializer therefore takes `offset` as an explicit
  argument (threaded from the calling tool's own `offset` param, defaulting to
  0\), and computes `has_more = offset + count < total`. It is
  `serialize_list(paginated, offset=...)`, not a pure one-arg function.
* Single `BaseModel` → `model.model_dump(mode="json")` (UUID→str, datetime→ISO;
  absolute times, matching the UI convention).
* `DataFrame` → the windowed shape above. Because `get_client()` forces the
  pandas backend, the serializer uses a single stable API (`df.dtypes`,
  `df.iloc[offset:offset+limit]`, `to_dict(orient="records")`) and does not need
  to branch on polars vs pandas.
* All returns are plain JSON-serializable dicts; FastMCP encodes the wire form.

## Error handling (`errors.py`)

* A `@tool_errors` decorator wraps each tool. It catches `IonworksError` (and
  subclasses) and returns a structured `{"error": {"code": ..., "message": ...}}`
  payload instead of letting the exception crash the MCP call. Mirrors the
  backend's "structured error, actionable message" philosophy.
* Missing credentials: the SDK's `Ionworks()` constructor raises a bare
  `ValueError` **eagerly** when no key is set. `get_client()` catches this and
  re-raises an `IonworksError` with a clear message
  (`"IONWORKS_API_KEY not set — configure it in your MCP server environment"`)
  so the `@tool_errors` boundary reports it as an `IonworksError` (a service
  error) rather than a generic `BadRequest`. Surfaced on the first tool call
  that constructs the client.
* Empty results are normal empty `items`, not errors.
* Tools do not individually try/except; the decorator is the single boundary.

## Testing

* `just test-mcp` recipe, mirroring the other `test-*` package recipes.
* **Unit tests only, no network.** `conftest.py` supplies a `fake_client`
  fixture — a stub `Ionworks` whose sub-clients return canned
  `PaginatedList` / `BaseModel` / `DataFrame` objects; `get_client()` is
  monkeypatched to return it.
* Per-tool coverage: (a) delegates to the right SDK method with the right args,
  (b) serializes correctly, (c) DataFrame tools window/truncate and set
  `truncated` / `total_rows`, (d) `columns` projection drops unrequested columns,
  (e) `IonworksError` → structured error payload, (f) missing-key path.
* `test_server.py` asserts all 15 tools register on the FastMCP instance.
* No live-backend test in v1; README documents how to smoke-test with a real key.

## Future work

* Add the entity-browsing tools (projects, studies, models, parameterized
  models, simulations) if needed.
* Add heavier reads (`simulation.get_result`, ECM/optimization/protocol).
* Expose the remaining discovery methods (`client.schema(name)`,
  `client.pybamm_models()`) as tools.
* Write tools (guarded, opt-in).
* A remotely-hosted MCP endpoint on the backend for zero-install multi-tenant
  access — most tool code carries over.
