Skip to main content

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)CellSpecificationNOTE: 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.pyget_client() lazy singleton, forces pandas backend
  • packages/ionworks-mcp/ionworks_mcp/errors.py@tool_errors decorator
  • packages/ionworks-mcp/ionworks_mcp/serialize.pyserialize_model, serialize_list, serialize_dataframe
  • packages/ionworks-mcp/ionworks_mcp/tools/__init__.pyregister_all(mcp)
  • packages/ionworks-mcp/ionworks_mcp/tools/discovery.pydiscover_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__.pymain() 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
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
  • 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:
  • 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:
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

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
  • 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).
  • Step 4: Create tests/__init__.py (empty file) and tests/_fakes.py
tests/__init__.py: empty. tests/_fakes.py:
  • Step 5: Create tests/conftest.py with the fixtures
  • 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

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
  • Step 2: Run to verify failure
Run: just test-mcp tests/test_errors.py -v Expected: FAIL (module missing).
  • Step 3: Implement errors.py
  • Step 4: Run to verify pass
Run: just test-mcp tests/test_errors.py -v Expected: PASS (3 tests).
  • Step 5: Commit

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
  • Step 2: Run to verify failure
Run: just test-mcp tests/test_serialize.py -v Expected: FAIL (module missing).
  • Step 3: Implement serialize.py
  • Step 4: Run to verify pass
Run: just test-mcp tests/test_serialize.py -v Expected: PASS (all serialize tests).
  • Step 5: Commit

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
  • 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
  • Step 4: Implement tools/cells.py
  • Step 5: Implement tools/__init__.py (register discovery + cells now; the other two modules are appended in later tasks)
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
(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

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
  • 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)
  • 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

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
  • 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)
  • Step 4: Run to verify pass
Run: just test-mcp tests/test_tools.py -k "material" -v Expected: PASS.
  • Step 5: Commit

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
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
  • Step 4: Implement __main__.py
  • 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

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:
Expected: prints the 15 tool names. (Optional deeper check: run ionworks-mcp and connect from an MCP client.)
  • Step 3: Commit

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.