Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ AssetOpsBench is a **unified framework for developing, orchestrating, and evalua

| MCP Servers | Important tools |
|---|---|
| **IoT** | `get_sites`, `get_history`, `get_assets`, `get_sensors` |
| **IoT** | `sites`, `asset_ids`, `assets` |
| **FMSR** | `get_failure_modes`, `generate_failure_modes`, `add_failure_modes`, `generate_failure_mode_sensor_mapping` |
| **TSFM** | `forecasting`, `timeseries_anomaly_detection` |
| **WO** | `get_work_order_distribution`, `predict_next_work_order`, ... |
Expand All @@ -110,7 +110,7 @@ The `src/` directory contains MCP servers and a plan-execute runner built on the

| Domain | Example Task |
|---|---|
| **IoT** | "List all sensors of Chiller 6 in MAIN site" |
| **IoT** | "List all assets in MAIN site" |
| **FMSR** | "List known failure modes for asset class pump" |
| **TSFM** | "Forecast Chiller 9 Condenser Water Flow for the week of 2020-04-27" |
| **WO** | "Generate a work order for Chiller 6 anomaly detection" |
Expand Down
42 changes: 17 additions & 25 deletions docs/mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,38 @@ Six FastMCP servers expose the AssetOpsBench domain logic. Each is a standalone

## Contents

- [iot — IoT Sensor Data](#iot--iot-sensor-data)
- [iot — IoT Asset Registry](#iot--iot-asset-registry)
- [utilities — Utilities](#utilities--utilities)
- [fmsr — Failure Mode and Sensor Relations](#fmsr--failure-mode-and-sensor-relations)
- [wo — Work Order](#wo--work-order)
- [tsfm — Time Series Foundation Model](#tsfm--time-series-foundation-model)
- [vibration — Vibration Diagnostics](#vibration--vibration-diagnostics)

## iot — IoT Sensor Data
## iot — IoT Asset Registry

The IoT server reads from **two** databases: telemetry readings (`IOT_DBNAME`, default `iot`) and an
asset **registry** (`ASSET_DBNAME`, default `asset`, loaded from `asset_profile_sample.json`). The
two answer different questions: `assets()`/`sensors()` reflect TELEMETRY — what actually streams (the
**measured** set); `get_asset()`/`asset_sensors()`/`registry_assets()` reflect the REGISTRY — the
asset nameplate and the **installed** sensor inventory (by name). Comparing `asset_sensors()` against
`sensors()` surfaces sensors that are installed but not streaming. The registry also reconciles ids
across systems (Maximo `assetnum`, telemetry `iot_asset_id`, work-order `wo_assetnum`), so an asset
can be looked up by any of its ids.
The IoT server reads from the asset **registry** (`ASSET_DBNAME`, default `asset`, loaded from
`asset_profile_sample.json`). It exposes registry discovery tools while the broader IoT tool surface
is being rebuilt: `sites()` for site names, `asset_ids()` for bare `assetnum` values, and `assets()`
for registry metadata with optional `assettype` filtering.

**Path:** `src/servers/iot/main.py`
**Requires:** CouchDB (`COUCHDB_URL`, `COUCHDB_USERNAME`, `COUCHDB_PASSWORD`, `IOT_DBNAME`, `ASSET_DBNAME`)
**Requires:** CouchDB (`COUCHDB_URL`, `COUCHDB_USERNAME`, `COUCHDB_PASSWORD`, `ASSET_DBNAME`)

**Sample assets shipped in the `iot` database** (loaded by `src/couchdb/couchdb_setup.sh`):
**Sample asset profiles shipped in the `asset` database** (loaded by `src/couchdb/init_data.py`):

| `asset_id` | Asset class | Source file |
| ----------- | ---------------- | ------------------------------------------------- |
| `Chiller 6` | Chiller | `src/couchdb/sample_data/iot/chiller_6.json` |
| `mp_1` | Metro pump | `src/couchdb/sample_data/iot/metro_pump_1.json` |
| `hyd_1` | Hydraulic pump | `src/couchdb/sample_data/iot/hydraulic_pump_1.json` |
| `assetnum` | Asset class |
| ----------- | ---------------- |
| `Chiller 6` | Chiller |
| `mp_1` | Compressor |
| `hyd_1` | Hydraulic pump |

Synthetic motor vibration data (`asset_id: Motor_01`, from `motor_01.json`) ships in a separate `vibration` database for the vibration MCP server.
Source file: `src/couchdb/scenarios_data/shared/iot/asset_profile_sample.json`.

| Tool | Arguments | Description |
| ----------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `sites` | — | List all sites, discovered dynamically from the asset registry (`siteid`) |
| `assets` | `site_name` | List asset ids registered at a site (telemetry id where present, else `assetnum`) |
| `sensors` | `site_name`, `asset_id` | List **measured** sensor names for an asset (union of keys across its telemetry docs) |
| `history` | `site_name`, `asset_id`, `start`, `final?` | Fetch historical sensor readings for a time range (ISO 8601 timestamps) |
| `get_asset` | `site_name`, `asset_id` | Registry/nameplate detail for one asset (description, assettype, status, location, vintage, installed count) |
| `asset_sensors` | `site_name`, `asset_id` | List the **installed** sensors for an asset, by name (registry inventory) |
| `registry_assets` | `site_name`, `assettype?` | List registry assets with metadata (assettype, vintage, sensor count), optionally filtered by assettype |
| `sites` | - | List known site names from the asset registry, with a default fallback |
| `asset_ids` | `site_name` | List bare `assetnum` values registered at a site |
| `assets` | `site_name`, `assettype?` | List assets with metadata (assettype, description, vintage, installed sensor count), optionally filtered by assettype |

## utilities — Utilities

Expand Down
2 changes: 1 addition & 1 deletion docs/opencode-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ uv run opencode-agent --show-trajectory \
```

In the `--show-trajectory` output, look for domain tool calls such as
`iot_sites`, `iot_registry_assets`, or `wo_list_workorders`. That confirms
`iot_sites`, `iot_asset_ids`, `iot_assets`, or `wo_list_workorders`. That confirms
OpenCode discovered and called the AssetOpsBench MCP tools.

> **Quiet runs.** `opencode-agent` runs OpenCode as a subprocess. During long
Expand Down
22 changes: 7 additions & 15 deletions docs/tool_universe.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,29 @@ from mcphub import ToolUniverse
tu = ToolUniverse() # 1. init
tu.load_tools() # 2. load (connect + discover)
tu.run({ # 3. run
"name": "iot.sensors",
"arguments": {"site_name": "MAIN", "asset_id": "Chiller 6"},
"name": "iot.asset_ids",
"arguments": {"site_name": "MAIN"},
})
tu.close()
```

`load_tools(servers=[...])` limits to specific servers. Tools are namespaced
`<server>.<tool>`; a bare name (e.g. `sensors`) also works when unambiguous.
A shorthand `tu.run("iot.sensors", {...})` is accepted too.
`<server>.<tool>`; a bare name (e.g. `asset_ids`) also works when unambiguous.
A shorthand `tu.run("iot.asset_ids", {...})` is accepted too.

## Discovery

```python
tu.find_tools("failure mode") # keyword search over loaded tools
tu.list_tools() # all loaded tool + workflow names
tu.list_tools("fmsr") # tool names for one server
tu.tool_specification("iot.sensors")
tu.tool_specification("iot.asset_ids")
```

## Workflows

Composed workflows run through the **same `run` entrypoint**:

```python
tu.run({"name": "chiller_triage", "arguments": {"asset_id": "Chiller 6"}})
```

Add one by writing `fn(tu, **arguments)` in `workflows.py` and listing its name
in `REGISTERED` (or `tu.register_workflow("name", fn)` at runtime). Built in:
`chiller_triage` (sensors → failure modes → mapping → work order) and
`sensor_inventory_gap` (installed vs measured sensors).
in `REGISTERED` (or `tu.register_workflow("name", fn)` at runtime).

## Run

Expand All @@ -48,4 +40,4 @@ uv run python examples/quickstart_tooluniverse.py
```

Each server launches via `uv run <server>-mcp-server` and inherits your
environment. Override with `ToolUniverse(servers={...})` if you run outside `uv`.
environment. Override with `ToolUniverse(servers={...})` if you run outside `uv`.
15 changes: 7 additions & 8 deletions examples/quickstart_tooluniverse.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,18 @@ def main():
[s["name"] for s in tu.find_tools("failure mode")])

# 3. run a single tool (ToolUniverse dict form)
show("iot.sensors", tu.run({
"name": "iot.sensors",
"arguments": {"site_name": "MAIN", "asset_id": "Chiller 6"},
show("iot.asset_ids", tu.run({
"name": "iot.asset_ids",
"arguments": {"site_name": "MAIN"},
}))

# A workflow runs through the same entrypoint
show("chiller_triage", tu.run({
"name": "chiller_triage",
"arguments": {"asset_id": "Chiller 6", "raise_work_order": False},
show("iot.assets", tu.run({
"name": "iot.assets",
"arguments": {"site_name": "MAIN", "assettype": "CHILLER"},
}))
finally:
tu.close()


if __name__ == "__main__":
main()
main()
26 changes: 1 addition & 25 deletions src/couchdb/scenarios_data/shared/iot/asset_profile_sample.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
[
{
"_id": "asset:CHILLER6",
"doctype": "asset",
"assetnum": "CHILLER6",
"iot_asset_id": "Chiller 6",
"wo_assetnum": "CHILLER6",
"assetnum": "Chiller 6",
"description": "Chiller 6 (chilled-water plant)",
"siteid": "MAIN",
"orgid": "RIVPLANT",
Expand Down Expand Up @@ -35,11 +31,7 @@
]
},
{
"_id": "asset:mp_1",
"doctype": "asset",
"assetnum": "mp_1",
"iot_asset_id": "mp_1",
"wo_assetnum": null,
"description": "Compressor / pneumatic system",
"siteid": "MAIN",
"orgid": "RIVPLANT",
Expand Down Expand Up @@ -73,11 +65,7 @@
]
},
{
"_id": "asset:hyd_1",
"doctype": "asset",
"assetnum": "hyd_1",
"iot_asset_id": "hyd_1",
"wo_assetnum": null,
"description": "Hydraulic pump condition-monitoring rig",
"siteid": "MAIN",
"orgid": "RIVPLANT",
Expand Down Expand Up @@ -114,11 +102,7 @@
]
},
{
"_id": "asset:Motor_01",
"doctype": "asset",
"assetnum": "Motor_01",
"iot_asset_id": "Motor_01",
"wo_assetnum": null,
"description": "Induction motor (vibration-monitored)",
"siteid": "MAIN",
"orgid": "RIVPLANT",
Expand All @@ -138,11 +122,7 @@
]
},
{
"_id": "asset:PUMP3",
"doctype": "asset",
"assetnum": "PUMP3",
"iot_asset_id": null,
"wo_assetnum": "PUMP3",
"description": "Pump 3",
"siteid": "MAIN",
"orgid": "RIVPLANT",
Expand All @@ -160,11 +140,7 @@
"sensors": []
},
{
"_id": "asset:AHU2",
"doctype": "asset",
"assetnum": "AHU2",
"iot_asset_id": null,
"wo_assetnum": "AHU2",
"description": "Air handling unit 2",
"siteid": "NORTH",
"orgid": "RIVPLANT",
Expand Down
10 changes: 5 additions & 5 deletions src/mcphub/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
tu = ToolUniverse() # 1. init
tu.load_tools() # 2. load (connect + discover)
tu.run({ # 3. run
"name": "iot.sensors",
"arguments": {"site_name": "MAIN", "asset_id": "Chiller 6"},
"name": "iot.asset_ids",
"arguments": {"site_name": "MAIN"},
})

Tools are namespaced ``<server>.<tool>``; a bare tool name also works when it is
Expand Down Expand Up @@ -202,8 +202,8 @@ def load_tools(self, servers: Optional[List[str]] = None) -> int:
def run(self, query, arguments: Optional[dict] = None) -> Any:
"""Execute a tool or workflow.

ToolUniverse form: run({"name": "iot.sensors", "arguments": {...}})
Shorthand: run("iot.sensors", {...})
ToolUniverse form: run({"name": "iot.asset_ids", "arguments": {...}})
Shorthand: run("iot.asset_ids", {...})
"""
if isinstance(query, dict):
name = query["name"]
Expand Down Expand Up @@ -282,4 +282,4 @@ def _resolve(self, name: str):


# Backwards-compatible alias.
ToolClient = ToolUniverse
ToolClient = ToolUniverse
41 changes: 10 additions & 31 deletions src/mcphub/tests/test_workflows.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,17 @@
from mcphub.workflows import chiller_triage
import pytest

from mcphub import workflows

class FakeToolUniverse:
def __init__(self):
self.calls = []

def run(self, name, arguments):
self.calls.append((name, arguments))
if name == "iot.sensors":
return {"sensors": ["Pressure sensor", "Vibration sensor"]}
if name == "fmsr.get_failure_modes":
return {
"asset_class": "pump",
"failure_modes": ["seal leakage", "impeller wear"],
}
if name == "fmsr.generate_failure_mode_sensor_mapping":
return {"metadata": arguments}
raise AssertionError(f"unexpected tool call: {name}")
def test_iot_sensor_workflows_are_not_registered():
assert workflows.REGISTERED == []


def test_chiller_triage_passes_lists_to_mapping_tool():
tu = FakeToolUniverse()
def test_chiller_triage_is_disabled():
with pytest.raises(RuntimeError, match="does not expose sensor tools"):
workflows.chiller_triage(object(), asset_id="Pump-1")

result = chiller_triage(tu, asset_id="Pump-1", raise_work_order=False)

mapping_call = [
arguments
for name, arguments in tu.calls
if name == "fmsr.generate_failure_mode_sensor_mapping"
][0]
assert mapping_call == {
"asset_class": "pump",
"failure_modes": ["seal leakage", "impeller wear"],
"sensors": ["Pressure sensor", "Vibration sensor"],
}
assert result["failure_modes_result"]["asset_class"] == "pump"
assert result["failure_modes"] == ["seal leakage", "impeller wear"]
def test_sensor_inventory_gap_is_disabled():
with pytest.raises(RuntimeError, match="does not expose sensor tools"):
workflows.sensor_inventory_gap(object(), asset_id="Pump-1")
Loading