Skip to main content

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

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 = 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 downloadtime_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 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 BaseModelmodel.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.