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/IonworksErrorimportable from top-levelionworkspackage (from ionworks import set_dataframe_backend, IonworksError).PaginatedList(from ionworks import PaginatedList) has ONLY.items,.total, andlen()(.countvia__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: noinclude_componentsparam. The spec’s tool table listsinclude_componentsforget_cell_spec, but the SDKgetdoes not accept it; implementget_cell_spec(cell_spec_id)only (list-levelinclude_componentsstill exists oncell_spec.list).Ionworks()raises a bareValueErroreagerly in__init__(client.py:145-149) when noIONWORKS_API_KEYis set — NOT on first request.get_client()must translate this to a clearIonworksError(see Task 2).cell_instance.list(cell_spec_id, limit=None, offset=None, *, name=None, ...)→PaginatedList[CellInstance]cell_instance.get(cell_instance_id)→CellInstancecell_measurement.list(cell_instance_id, limit=None, offset=None, measurement_type=None, *, name=None, ...)→PaginatedList[CellMeasurement]cell_measurement.get(measurement_id)→CellMeasurementcell_measurement.time_series(measurement_id, use_cache=True)→ DataFrame (no columns/limit/offset at SDK level)cell_measurement.steps(measurement_id, use_cache=True)→ DataFramecell_measurement.cycles(measurement_id, use_cache=True)→ DataFramematerial.list(limit=None, offset=None, *, project_id=None)→PaginatedList[Material]material.get(material_id)→Materialmaterial_property_dataset.list(material_id, project_id=None, limit=None, offset=None)→PaginatedList[MaterialPropertyDataset]material_property_dataset.get(dataset_id)→MaterialPropertyDatasetmaterial_property_dataset.get_data(dataset_id)→ DataFramematerial_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,devextra, entry point, pytest configpackages/ionworks-mcp/README.md— usage + MCP client config + smoke testpackages/ionworks-mcp/ionworks_mcp/__init__.py— version markerpackages/ionworks-mcp/ionworks_mcp/client.py—get_client()lazy singleton, forces pandas backendpackages/ionworks-mcp/ionworks_mcp/errors.py—@tool_errorsdecoratorpackages/ionworks-mcp/ionworks_mcp/serialize.py—serialize_model,serialize_list,serialize_dataframepackages/ionworks-mcp/ionworks_mcp/tools/__init__.py—register_all(mcp)packages/ionworks-mcp/ionworks_mcp/tools/discovery.py—discover_capabilitiespackages/ionworks-mcp/ionworks_mcp/tools/cells.py— 6 cell spec/instance/measurement list+get toolspackages/ionworks-mcp/ionworks_mcp/tools/measurement_data.py— 3 time_series/steps/cycles toolspackages/ionworks-mcp/ionworks_mcp/tools/materials.py— 5 material + property-dataset toolspackages/ionworks-mcp/ionworks_mcp/server.py— build FastMCP,register_all,run()stdiopackages/ionworks-mcp/ionworks_mcp/__main__.py—main()console entrypackages/ionworks-mcp/tests/conftest.py— fake client fixture + monkeypatch ofget_clientpackages/ionworks-mcp/tests/test_serialize.py— serializer unit testspackages/ionworks-mcp/tests/test_errors.py— decorator unit testspackages/ionworks-mcp/tests/test_tools.py— per-tool delegation/serialization/error testspackages/ionworks-mcp/tests/test_server.py— all-15-tools-registered testpyproject.toml(root) — addpackages/ionworks-mcpto workspace membersjustfile— addtest-mcprecipe
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— addtest-mcprecipe -
Step 1: Create
packages/ionworks-mcp/pyproject.toml
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
pyproject.toml (root), under [tool.uv.workspace] members = [...], add the line "packages/ionworks-mcp", alongside the other packages/* entries.
- Step 4: Add the
test-mcprecipe tojustfile
test-api recipe, add:
- Step 5: Sync the workspace so the new package +
mcpresolve
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)
_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:
['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 — makestestsimportable) -
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
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
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) andtests/_fakes.py
tests/__init__.py: empty.
tests/_fakes.py:
- Step 5: Create
tests/conftest.pywith the fixtures
- Step 6: Run the client tests to verify they pass
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
just test-mcp tests/test_errors.py -v
Expected: FAIL (module missing).
- Step 3: Implement
errors.py
- Step 4: Run to verify pass
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
just test-mcp tests/test_serialize.py -v
Expected: FAIL (module missing).
- Step 3: Implement
serialize.py
- Step 4: Run to verify pass
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
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
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)
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.pyandtools/materials.py
- Step 7: Run to verify pass
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
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
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
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
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
.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
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
just test-mcp tests/test_server.py -v
Expected: PASS (exactly 15 tools).
- Step 6: Run the full package suite
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
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)
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
just test-mcp -v
Expected: all green.
- Step 2: Confirm the workspace still resolves and other packages are unaffected
uv sync --all-packages
Expected: no errors.
- Step 3: Run prek / pre-commit on the changed files (use the
prekskill if available)
- Step 4: Final review of
git log --onelinefor 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 resolvedmcpversion, 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_errorsunder@mcp.tool(): the decorators stack so thatmcp.tool()registers the already-error-wrapped function.functools.wrapspreserves the signature/annotations FastMCP introspects for the tool schema. This is enforced bytest_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.