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 markedopenapi_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-apiPython 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 ownIONWORKS_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) launchesionworks-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
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
Materials
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 DataFrames 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 = 500rows, hard capmax = 5000rows. A requested limit above the cap is clamped (and the response notes it). - Optional
columnsprojects 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:
- Row/column counts and dtypes are always returned even when truncated, so the model knows the full shape.
- For
get_material_property_data, units fromget_units(dataset_id)are merged into the response (e.g. aunitsmap alongsidecolumns).
Serialization (serialize.py)
PaginatedList/list[BaseModel]→{"items": [...], "count": n, "total": t, "offset": o, "has_more": bool}.PaginatedListexposes only.items,.count, and.total(packages/ionworks-api/ionworks/models.py) — it has nooffset. The serializer therefore takesoffsetas an explicit argument (threaded from the calling tool’s ownoffsetparam, defaulting to 0), and computeshas_more = offset + count < total. It isserialize_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. Becauseget_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_errorsdecorator wraps each tool. It catchesIonworksError(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 bareValueErroreagerly when no key is set.get_client()catches this and re-raises anIonworksErrorwith a clear message ("IONWORKS_API_KEY not set — configure it in your MCP server environment") so the@tool_errorsboundary reports it as anIonworksError(a service error) rather than a genericBadRequest. 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-mcprecipe, mirroring the othertest-*package recipes.- Unit tests only, no network.
conftest.pysupplies afake_clientfixture — a stubIonworkswhose sub-clients return cannedPaginatedList/BaseModel/DataFrameobjects;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)columnsprojection drops unrequested columns, (e)IonworksError→ structured error payload, (f) missing-key path. test_server.pyasserts 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.