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
16 changes: 16 additions & 0 deletions src/servers/fmsr/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,19 @@ def _missing_asset_class_error(original: str, normalized: str) -> ErrorResult:
return ErrorResult(error=message)


def _is_missing_database(exc: Exception) -> bool:
return "Database does not exist" in str(exc)


_MISSING_DATABASE_ERROR = (
"the data source does not exist in this environment; the "
"data is unavailable, do not retry with other arguments"
)


def _is_not_found_error(exc: Exception) -> bool:
if _is_missing_database(exc):
return False
if isinstance(exc, (KeyError, NotFoundError)):
return True
response = getattr(exc, "response", None)
Expand Down Expand Up @@ -267,6 +279,8 @@ def _find_failure_mode_doc(asset_class: str) -> Optional[dict]:
try:
d = fm_db.get(f"fm:{key}", check=True)
except Exception as exc: # noqa: BLE001
if _is_missing_database(exc):
raise RuntimeError(_MISSING_DATABASE_ERROR) from exc
if _is_not_found_error(exc):
d = None
else:
Expand All @@ -281,6 +295,8 @@ def _find_failure_mode_doc(asset_class: str) -> Optional[dict]:
d = docs[0]
return d
except Exception as exc: # noqa: BLE001
if _is_missing_database(exc):
raise RuntimeError(_MISSING_DATABASE_ERROR) from exc
raise RuntimeError(
f"database lookup failed for asset_class '{key}': {exc}"
) from exc
Expand Down
22 changes: 22 additions & 0 deletions src/servers/fmsr/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,25 @@ async def test_mapping_tool_is_not_registered(self):
assert "generate_failure_mode_sensor_mapping" not in {
tool.name for tool in tools
}


class TestMissingDatabaseMessage:
@pytest.mark.anyio
async def test_missing_database_reports_unavailable(self, monkeypatch):
from couchdb3.exceptions import NotFoundError

class MissingDatabase:
def get(self, *args, **kwargs):
raise NotFoundError(
'{"error":"not_found","reason":"Database does not exist."}'
)

find = get

monkeypatch.setattr("servers.fmsr.main.fm_db", MissingDatabase())

data = await call_tool(mcp, "get_failure_modes", {"asset_class": "pump"})

assert "does not exist in this environment" in data["error"]
assert "failure_mode" not in data["error"]
assert "no failure_mode record" not in data["error"]
88 changes: 68 additions & 20 deletions src/servers/iot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,19 @@ def known_sites() -> List[str]:
return get_registry_sites() or DEFAULT_SITES


def _missing_db_error(db: Any) -> Optional[ErrorResult]:
"""Return an error when the database itself is absent or unreachable, so a
missing database is not reported as an unknown key."""
if db is not None and db.check():
return None
return ErrorResult(
error=(
"the data source does not exist or is unreachable in this "
"environment; the data is unavailable, do not retry with other arguments"
)
)


def _is_known_site(site_name: str) -> bool:
return site_name in known_sites()

Expand Down Expand Up @@ -224,7 +237,7 @@ def asset_ids(site_name: str) -> Union[AssetsResult, ErrorResult]:
)
except Exception as e:
logger.error(f"asset_ids failed: {e}")
return ErrorResult(error=str(e))
return _missing_db_error(asset_db) or ErrorResult(error=str(e))


@mcp.tool(title="Get Asset Detail")
Expand Down Expand Up @@ -265,7 +278,9 @@ def asset_detail(site_name: str, asset_id: str) -> Union[AssetDetail, ErrorResul
)
docs = res.get("docs", [])
if not docs:
return ErrorResult(error=f"unknown asset_id {asset_id} at site {site_name}")
return _missing_db_error(asset_db) or ErrorResult(
error=f"unknown asset_id {asset_id} at site {site_name}"
)

doc = docs[0]
sensors = list(doc.get("sensors") or [])
Expand Down Expand Up @@ -295,7 +310,7 @@ def asset_detail(site_name: str, asset_id: str) -> Union[AssetDetail, ErrorResul
)
except Exception as e:
logger.error(f"asset_detail failed: {e}")
return ErrorResult(error=str(e))
return _missing_db_error(asset_db) or ErrorResult(error=str(e))


@mcp.tool(title="List Measured Sensors")
Expand All @@ -322,7 +337,9 @@ def measured_sensors(

sensor_list = get_sensor_list(asset_id)
if not sensor_list:
return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found")
return _missing_db_error(iot_db) or ErrorResult(
error=f"unknown asset_id {asset_id} or no sensors found"
)

return SensorsResult(
site_name=site_name,
Expand Down Expand Up @@ -366,7 +383,9 @@ def installed_sensors(
)
docs = res.get("docs", [])
if not docs:
return ErrorResult(error=f"unknown asset_id {asset_id} at site {site_name}")
return _missing_db_error(asset_db) or ErrorResult(
error=f"unknown asset_id {asset_id} at site {site_name}"
)
names = list(docs[0].get("sensors") or [])
return SensorsResult(
site_name=site_name,
Expand All @@ -377,7 +396,7 @@ def installed_sensors(
)
except Exception as e:
logger.error(f"installed_sensors failed: {e}")
return ErrorResult(error=str(e))
return _missing_db_error(asset_db) or ErrorResult(error=str(e))


@mcp.tool(title="List Assets")
Expand Down Expand Up @@ -435,7 +454,7 @@ def assets(
)
except Exception as e:
logger.error(f"assets failed: {e}")
return ErrorResult(error=str(e))
return _missing_db_error(asset_db) or ErrorResult(error=str(e))


@mcp.tool(title="Find Assets By Sensors")
Expand Down Expand Up @@ -478,9 +497,15 @@ def find_assets_by_sensors(
if source == "measured" and not iot_db:
return ErrorResult(error="IoT records database not connected")

site_asset_ids = _site_asset_ids(site_name)
if not site_asset_ids:
missing = _missing_db_error(asset_db)
if missing:
return missing

query_sensors = list(dict.fromkeys(sensors))
matches: List[AssetSensorMatch] = []
for asset_id in _site_asset_ids(site_name):
for asset_id in site_asset_ids:
available = (
get_sensor_list(asset_id)
if source == "measured"
Expand Down Expand Up @@ -522,6 +547,11 @@ def _hits(sensor_name: str) -> List[str]:
AssetSensorMatch(asset_id=asset_id, matched_sensors=matched)
)

if not matches and source == "measured":
missing = _missing_db_error(iot_db)
if missing:
return missing

return FindAssetsResult(
site_name=site_name,
query_sensors=query_sensors,
Expand Down Expand Up @@ -606,7 +636,7 @@ def stream_extent(
total_records += 1

if total_records == 0:
return ErrorResult(
return _missing_db_error(iot_db) or ErrorResult(
error=f"no records for asset_id {asset_id}"
+ (f", sensor {sensor}" if sensor else "")
)
Expand Down Expand Up @@ -638,7 +668,9 @@ def stream_extent(
return ErrorResult(error=str(e))
except Exception as e:
logger.error(f"stream_extent failed: {e}")
return ErrorResult(error="unable to inspect telemetry stream extent")
return _missing_db_error(iot_db) or ErrorResult(
error="unable to inspect telemetry stream extent"
)


@mcp.tool(title="Get Sensor History")
Expand Down Expand Up @@ -704,7 +736,9 @@ def history(
)
available_sensors = get_sensor_list(asset_id)
if not available_sensors:
return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found")
return _missing_db_error(iot_db) or ErrorResult(
error=f"unknown asset_id {asset_id} or no sensors found"
)
unknown = [
sensor for sensor in selected_sensors if sensor not in available_sensors
]
Expand Down Expand Up @@ -763,7 +797,9 @@ def history(
return ErrorResult(error=str(e))
except Exception as e:
logger.error(f"history failed: {e}")
return ErrorResult(error="unable to retrieve telemetry history")
return _missing_db_error(iot_db) or ErrorResult(
error="unable to retrieve telemetry history"
)

next_cursor = None
if has_more:
Expand Down Expand Up @@ -828,7 +864,9 @@ def latest_reading(
if sensor is not None:
available_sensors = get_sensor_list(asset_id)
if not available_sensors:
return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found")
return _missing_db_error(iot_db) or ErrorResult(
error=f"unknown asset_id {asset_id} or no sensors found"
)
if sensor not in available_sensors:
return ErrorResult(error=f"unknown sensor {sensor} for asset_id {asset_id}")

Expand Down Expand Up @@ -856,10 +894,12 @@ def latest_reading(
return ErrorResult(error=str(e))
except Exception as e:
logger.error(f"latest_reading failed: {e}")
return ErrorResult(error="unable to retrieve latest telemetry reading")
return _missing_db_error(iot_db) or ErrorResult(
error="unable to retrieve latest telemetry reading"
)

if latest_doc is None or latest_timestamp is None or latest_datetime is None:
return ErrorResult(
return _missing_db_error(iot_db) or ErrorResult(
error=f"no records for asset_id {asset_id}"
+ (f", sensor {sensor}" if sensor else "")
)
Expand Down Expand Up @@ -930,10 +970,14 @@ def sensor_coverage(
return ErrorResult(error=str(e))
except Exception as e:
logger.error(f"sensor_coverage failed: {e}")
return ErrorResult(error="unable to calculate sensor coverage")
return _missing_db_error(iot_db) or ErrorResult(
error="unable to calculate sensor coverage"
)

if docs_scanned == 0:
return ErrorResult(error=f"unknown asset_id {asset_id} or no records found")
return _missing_db_error(iot_db) or ErrorResult(
error=f"unknown asset_id {asset_id} or no records found"
)

sensors = [coverage[field].result(field) for field in sorted(coverage)]
message = (
Expand Down Expand Up @@ -996,7 +1040,9 @@ def sensor_stats(

available_sensors = get_sensor_list(asset_id)
if not available_sensors:
return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found")
return _missing_db_error(iot_db) or ErrorResult(
error=f"unknown asset_id {asset_id} or no sensors found"
)
if sensor is not None and sensor not in available_sensors:
return ErrorResult(error=f"unknown sensor {sensor} for asset_id {asset_id}")

Expand Down Expand Up @@ -1035,10 +1081,12 @@ def sensor_stats(
return ErrorResult(error=str(e))
except Exception as e:
logger.error(f"sensor_stats failed: {e}")
return ErrorResult(error="unable to calculate sensor statistics")
return _missing_db_error(iot_db) or ErrorResult(
error="unable to calculate sensor statistics"
)

if records_in_window == 0:
return ErrorResult(
return _missing_db_error(iot_db) or ErrorResult(
error=f"no records for asset_id {asset_id}"
+ (f", sensor {sensor}" if sensor else "")
)
Expand Down
46 changes: 46 additions & 0 deletions src/servers/iot/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1795,3 +1795,49 @@ async def test_discovery_integration(self):
assert "assets" in data
assert any(asset["asset_id"] == "Chiller 6" for asset in data["assets"])
assert data["total_assets"] > 0


class TestMissingDatabaseMessage:
@pytest.mark.anyio
async def test_wrong_asset_id_reports_unknown_key(self, mock_asset_db, mock_iot_db):
mock_asset_db.find.return_value = {"docs": [{"siteid": "MAIN"}]}
mock_iot_db.find.return_value = {"docs": []}
mock_iot_db.check.return_value = True

data = await call_tool(
mcp, "measured_sensors", {"site_name": "MAIN", "asset_id": "Pump-X"}
)

assert data["error"] == "unknown asset_id Pump-X or no sensors found"

@pytest.mark.anyio
async def test_missing_database_reports_unavailable(
self, mock_asset_db, mock_iot_db
):
mock_asset_db.find.return_value = {"docs": [{"siteid": "MAIN"}]}
mock_iot_db.find.side_effect = RuntimeError("Database does not exist.")
mock_iot_db.check.return_value = False

data = await call_tool(
mcp, "measured_sensors", {"site_name": "MAIN", "asset_id": "Chiller 6"}
)

assert "does not exist or is unreachable" in data["error"]
assert "do not retry" in data["error"]
assert "iot" not in data["error"]

@pytest.mark.anyio
async def test_find_assets_with_missing_database_reports_unavailable(
self, mock_asset_db, mock_iot_db
):
mock_asset_db.find.return_value = {"docs": [{"siteid": "MAIN"}]}
mock_iot_db.find.side_effect = RuntimeError("Database does not exist.")
mock_iot_db.check.return_value = False

data = await call_tool(
mcp,
"find_assets_by_sensors",
{"site_name": "MAIN", "sensors": ["Temp"]},
)

assert "does not exist or is unreachable" in data["error"]
6 changes: 6 additions & 0 deletions src/servers/vibration/couchdb_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ def _get_db() -> Optional[couchdb3.Database]:
return None


def database_available() -> bool:
"""True when the vibration database exists and CouchDB is reachable."""
db = _get_db()
return bool(db and db.check())


def fetch_vibration_timeseries(
asset_id: str,
sensor_name: str,
Expand Down
Loading
Loading