From 6bbaba31ccd88bdf2ae877a73a148cefa268d57a Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Fri, 25 Sep 2026 16:12:37 -0400 Subject: [PATCH 1/3] fix: distinct error message when a CouchDB database is missing A missing or unreachable database was reported the same way as a wrong key (e.g. 'unknown asset_id', 'work order not found'). On not-found/failure paths the iot, fmsr, wo and vibration servers now report that the database does not exist in this environment and that retrying with other arguments won't help. Signed-off-by: Shuxin Lin --- src/servers/fmsr/main.py | 16 ++++ src/servers/fmsr/tests/test_tools.py | 21 ++++++ src/servers/iot/main.py | 75 ++++++++++++++----- src/servers/iot/tests/test_tools.py | 29 +++++++ src/servers/vibration/couchdb_client.py | 6 ++ src/servers/vibration/main.py | 25 ++++++- .../vibration/tests/test_missing_database.py | 29 +++++++ src/servers/wo/couch.py | 19 +++++ src/servers/wo/tests/test_missing_database.py | 34 +++++++++ 9 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 src/servers/vibration/tests/test_missing_database.py create mode 100644 src/servers/wo/tests/test_missing_database.py diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index f0a1244cf..1e293f24f 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -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 = ( + f"database '{FAILURE_MODE_DBNAME}' 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) @@ -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: @@ -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 diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index 9fd672e41..17138b4f1 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -293,3 +293,24 @@ 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 "no failure_mode record" not in data["error"] diff --git a/src/servers/iot/main.py b/src/servers/iot/main.py index 9e62f578e..77253bf15 100644 --- a/src/servers/iot/main.py +++ b/src/servers/iot/main.py @@ -145,6 +145,19 @@ def known_sites() -> List[str]: return get_registry_sites() or DEFAULT_SITES +def _missing_db_error(db: Any, name: str) -> 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=( + f"database '{name}' 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() @@ -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, ASSET_DBNAME) or ErrorResult(error=str(e)) @mcp.tool(title="Get Asset Detail") @@ -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, ASSET_DBNAME) or ErrorResult( + error=f"unknown asset_id {asset_id} at site {site_name}" + ) doc = docs[0] sensors = list(doc.get("sensors") or []) @@ -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, ASSET_DBNAME) or ErrorResult(error=str(e)) @mcp.tool(title="List Measured Sensors") @@ -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, IOT_DBNAME) or ErrorResult( + error=f"unknown asset_id {asset_id} or no sensors found" + ) return SensorsResult( site_name=site_name, @@ -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, ASSET_DBNAME) 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, @@ -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, ASSET_DBNAME) or ErrorResult(error=str(e)) @mcp.tool(title="List Assets") @@ -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, ASSET_DBNAME) or ErrorResult(error=str(e)) @mcp.tool(title="Find Assets By Sensors") @@ -606,7 +625,7 @@ def stream_extent( total_records += 1 if total_records == 0: - return ErrorResult( + return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) @@ -638,7 +657,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, IOT_DBNAME) or ErrorResult( + error="unable to inspect telemetry stream extent" + ) @mcp.tool(title="Get Sensor History") @@ -704,7 +725,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, IOT_DBNAME) 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 ] @@ -763,7 +786,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, IOT_DBNAME) or ErrorResult( + error="unable to retrieve telemetry history" + ) next_cursor = None if has_more: @@ -828,7 +853,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, IOT_DBNAME) 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}") @@ -856,10 +883,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, IOT_DBNAME) 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, IOT_DBNAME) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) @@ -930,10 +959,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, IOT_DBNAME) 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, IOT_DBNAME) or ErrorResult( + error=f"unknown asset_id {asset_id} or no records found" + ) sensors = [coverage[field].result(field) for field in sorted(coverage)] message = ( @@ -996,7 +1029,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, IOT_DBNAME) 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}") @@ -1035,10 +1070,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, IOT_DBNAME) or ErrorResult( + error="unable to calculate sensor statistics" + ) if records_in_window == 0: - return ErrorResult( + return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) diff --git a/src/servers/iot/tests/test_tools.py b/src/servers/iot/tests/test_tools.py index 8896275e7..d81688a6b 100644 --- a/src/servers/iot/tests/test_tools.py +++ b/src/servers/iot/tests/test_tools.py @@ -1795,3 +1795,32 @@ 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"] diff --git a/src/servers/vibration/couchdb_client.py b/src/servers/vibration/couchdb_client.py index ad9c013c7..327bfb8bb 100644 --- a/src/servers/vibration/couchdb_client.py +++ b/src/servers/vibration/couchdb_client.py @@ -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, diff --git a/src/servers/vibration/main.py b/src/servers/vibration/main.py index bcc8985b0..c06227ba0 100644 --- a/src/servers/vibration/main.py +++ b/src/servers/vibration/main.py @@ -19,7 +19,12 @@ from mcp.server.fastmcp import FastMCP from pydantic import BaseModel -from .couchdb_client import fetch_vibration_timeseries, list_sensor_fields +from .couchdb_client import ( + VIBRATION_DBNAME, + database_available, + fetch_vibration_timeseries, + list_sensor_fields, +) from .data_store import store from .dsp.bearing_freqs import ( COMMON_BEARINGS, @@ -58,6 +63,20 @@ class ErrorResult(BaseModel): error: str +def _missing_db_error() -> Optional[ErrorResult]: + """Return an error when the database itself is absent or unreachable, so a + missing database is not reported as missing asset/sensor data.""" + if database_available(): + return None + return ErrorResult( + error=( + f"database '{VIBRATION_DBNAME}' does not exist or is unreachable in " + "this environment; the data is unavailable, do not retry with other " + "arguments" + ) + ) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -155,7 +174,7 @@ def get_vibration_data( """ result = fetch_vibration_timeseries(asset_id, sensor_name, start, final) if result is None: - return ErrorResult( + return _missing_db_error() or ErrorResult( error=f"No vibration data found for asset '{asset_id}', " f"sensor '{sensor_name}' in time range starting {start}." ) @@ -190,7 +209,7 @@ def list_vibration_sensors( """ sensors = list_sensor_fields(asset_id) if not sensors: - return ErrorResult( + return _missing_db_error() or ErrorResult( error=f"No sensors found for asset '{asset_id}' at site '{site_name}'." ) return { diff --git a/src/servers/vibration/tests/test_missing_database.py b/src/servers/vibration/tests/test_missing_database.py new file mode 100644 index 000000000..de37c219d --- /dev/null +++ b/src/servers/vibration/tests/test_missing_database.py @@ -0,0 +1,29 @@ +"""A wrong asset and a missing database produce different error messages.""" + +import pytest + +from servers.vibration.main import mcp + +from .conftest import call_tool + +_ARGS = {"site_name": "MAIN", "asset_id": "Motor-X"} + + +@pytest.mark.anyio +async def test_wrong_asset_reports_no_sensors(mock_db): + mock_db.return_value.find.return_value = {"docs": []} + mock_db.return_value.check.return_value = True + + data = await call_tool(mcp, "list_vibration_sensors", _ARGS) + + assert data["error"] == "No sensors found for asset 'Motor-X' at site 'MAIN'." + + +@pytest.mark.anyio +async def test_missing_database_reports_unavailable(mock_db): + mock_db.return_value.find.side_effect = RuntimeError("Database does not exist.") + mock_db.return_value.check.return_value = False + + data = await call_tool(mcp, "list_vibration_sensors", _ARGS) + + assert "does not exist or is unreachable" in data["error"] diff --git a/src/servers/wo/couch.py b/src/servers/wo/couch.py index cd694cdff..ce32d40c8 100644 --- a/src/servers/wo/couch.py +++ b/src/servers/wo/couch.py @@ -45,9 +45,25 @@ def __init__( async def aclose(self) -> None: await self._c.aclose() + def _raise_if_missing_db(self, r: "httpx.Response") -> None: + """CouchDB answers 404 both for a missing doc and a missing database; + only the body's reason tells them apart.""" + if r.status_code != 404: + return + try: + reason = r.json().get("reason") + except ValueError: + return + if reason == "Database does not exist.": + raise CouchError( + f"database '{self.db}' does not exist in this environment; the " + "data is unavailable, do not retry with other arguments" + ) + # ---- document CRUD ---- async def get(self, doc_id: str) -> Optional[Dict[str, Any]]: r = await self._c.get(f"/{self.db}/{doc_id}") + self._raise_if_missing_db(r) if r.status_code == 404: return None r.raise_for_status() @@ -57,6 +73,7 @@ async def put(self, doc: Dict[str, Any]) -> Dict[str, Any]: if "_id" not in doc: raise CouchError("document must have _id") r = await self._c.put(f"/{self.db}/{doc['_id']}", json=doc) + self._raise_if_missing_db(r) if r.status_code == 409: raise CouchError(f"conflict updating {doc['_id']} (stale _rev)") r.raise_for_status() @@ -83,6 +100,7 @@ async def find( if sort: body["sort"] = sort r = await self._c.post(f"/{self.db}/_find", json=body) + self._raise_if_missing_db(r) r.raise_for_status() return r.json().get("docs", []) @@ -95,6 +113,7 @@ async def view(self, ddoc: str, view: str, **params: Any) -> Dict[str, Any]: for k, v in params.items() } r = await self._c.get(f"/{self.db}/_design/{ddoc}/_view/{view}", params=q) + self._raise_if_missing_db(r) r.raise_for_status() return r.json() diff --git a/src/servers/wo/tests/test_missing_database.py b/src/servers/wo/tests/test_missing_database.py new file mode 100644 index 000000000..a3539cbf3 --- /dev/null +++ b/src/servers/wo/tests/test_missing_database.py @@ -0,0 +1,34 @@ +"""A missing document and a missing database produce different outcomes.""" + +import httpx +import pytest + +from servers.wo.couch import CouchClient, CouchError + + +def _client(reason: str) -> CouchClient: + client = CouchClient("http://couch.test", "workorder") + client._c = httpx.AsyncClient( + base_url="http://couch.test", + transport=httpx.MockTransport( + lambda request: httpx.Response( + 404, json={"error": "not_found", "reason": reason} + ) + ), + ) + return client + + +@pytest.mark.anyio +async def test_missing_document_returns_none() -> None: + assert await _client("missing").get("wo:MAIN:1") is None + + +@pytest.mark.anyio +async def test_missing_database_raises_distinct_message() -> None: + client = _client("Database does not exist.") + + with pytest.raises(CouchError, match="database 'workorder' does not exist"): + await client.get("wo:MAIN:1") + with pytest.raises(CouchError, match="database 'workorder' does not exist"): + await client.find({"type": "workorder"}) From f2308128dd7f9088e9d23b5f7582e85fde6e7cb2 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Fri, 25 Sep 2026 16:15:33 -0400 Subject: [PATCH 2/3] fix: do not expose database names in missing-database errors Signed-off-by: Shuxin Lin --- src/servers/fmsr/main.py | 2 +- src/servers/fmsr/tests/test_tools.py | 1 + src/servers/iot/main.py | 42 +++++++++---------- src/servers/iot/tests/test_tools.py | 1 + src/servers/vibration/main.py | 6 +-- .../vibration/tests/test_missing_database.py | 1 + src/servers/wo/couch.py | 2 +- src/servers/wo/tests/test_missing_database.py | 12 +++++- 8 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index 1e293f24f..d72d73740 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -90,7 +90,7 @@ def _is_missing_database(exc: Exception) -> bool: _MISSING_DATABASE_ERROR = ( - f"database '{FAILURE_MODE_DBNAME}' does not exist in this environment; the " + "the data source does not exist in this environment; the " "data is unavailable, do not retry with other arguments" ) diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index 17138b4f1..407324e49 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -313,4 +313,5 @@ def get(self, *args, **kwargs): 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"] diff --git a/src/servers/iot/main.py b/src/servers/iot/main.py index 77253bf15..6566518dc 100644 --- a/src/servers/iot/main.py +++ b/src/servers/iot/main.py @@ -145,14 +145,14 @@ def known_sites() -> List[str]: return get_registry_sites() or DEFAULT_SITES -def _missing_db_error(db: Any, name: str) -> Optional[ErrorResult]: +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=( - f"database '{name}' does not exist or is unreachable in this " + "the data source does not exist or is unreachable in this " "environment; the data is unavailable, do not retry with other arguments" ) ) @@ -237,7 +237,7 @@ def asset_ids(site_name: str) -> Union[AssetsResult, ErrorResult]: ) except Exception as e: logger.error(f"asset_ids failed: {e}") - return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(error=str(e)) + return _missing_db_error(asset_db) or ErrorResult(error=str(e)) @mcp.tool(title="Get Asset Detail") @@ -278,7 +278,7 @@ def asset_detail(site_name: str, asset_id: str) -> Union[AssetDetail, ErrorResul ) docs = res.get("docs", []) if not docs: - return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult( + return _missing_db_error(asset_db) or ErrorResult( error=f"unknown asset_id {asset_id} at site {site_name}" ) @@ -310,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 _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(error=str(e)) + return _missing_db_error(asset_db) or ErrorResult(error=str(e)) @mcp.tool(title="List Measured Sensors") @@ -337,7 +337,7 @@ def measured_sensors( sensor_list = get_sensor_list(asset_id) if not sensor_list: - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"unknown asset_id {asset_id} or no sensors found" ) @@ -383,7 +383,7 @@ def installed_sensors( ) docs = res.get("docs", []) if not docs: - return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult( + 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 []) @@ -396,7 +396,7 @@ def installed_sensors( ) except Exception as e: logger.error(f"installed_sensors failed: {e}") - return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(error=str(e)) + return _missing_db_error(asset_db) or ErrorResult(error=str(e)) @mcp.tool(title="List Assets") @@ -454,7 +454,7 @@ def assets( ) except Exception as e: logger.error(f"assets failed: {e}") - return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(error=str(e)) + return _missing_db_error(asset_db) or ErrorResult(error=str(e)) @mcp.tool(title="Find Assets By Sensors") @@ -625,7 +625,7 @@ def stream_extent( total_records += 1 if total_records == 0: - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) @@ -657,7 +657,7 @@ def stream_extent( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"stream_extent failed: {e}") - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error="unable to inspect telemetry stream extent" ) @@ -725,7 +725,7 @@ def history( ) available_sensors = get_sensor_list(asset_id) if not available_sensors: - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"unknown asset_id {asset_id} or no sensors found" ) unknown = [ @@ -786,7 +786,7 @@ def history( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"history failed: {e}") - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error="unable to retrieve telemetry history" ) @@ -853,7 +853,7 @@ def latest_reading( if sensor is not None: available_sensors = get_sensor_list(asset_id) if not available_sensors: - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + 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: @@ -883,12 +883,12 @@ def latest_reading( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"latest_reading failed: {e}") - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + 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 _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) @@ -959,12 +959,12 @@ def sensor_coverage( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"sensor_coverage failed: {e}") - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error="unable to calculate sensor coverage" ) if docs_scanned == 0: - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"unknown asset_id {asset_id} or no records found" ) @@ -1029,7 +1029,7 @@ def sensor_stats( available_sensors = get_sensor_list(asset_id) if not available_sensors: - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + 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: @@ -1070,12 +1070,12 @@ def sensor_stats( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"sensor_stats failed: {e}") - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error="unable to calculate sensor statistics" ) if records_in_window == 0: - return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) diff --git a/src/servers/iot/tests/test_tools.py b/src/servers/iot/tests/test_tools.py index d81688a6b..632d4efdf 100644 --- a/src/servers/iot/tests/test_tools.py +++ b/src/servers/iot/tests/test_tools.py @@ -1824,3 +1824,4 @@ async def test_missing_database_reports_unavailable( assert "does not exist or is unreachable" in data["error"] assert "do not retry" in data["error"] + assert "iot" not in data["error"] diff --git a/src/servers/vibration/main.py b/src/servers/vibration/main.py index c06227ba0..41118e300 100644 --- a/src/servers/vibration/main.py +++ b/src/servers/vibration/main.py @@ -20,7 +20,6 @@ from pydantic import BaseModel from .couchdb_client import ( - VIBRATION_DBNAME, database_available, fetch_vibration_timeseries, list_sensor_fields, @@ -70,9 +69,8 @@ def _missing_db_error() -> Optional[ErrorResult]: return None return ErrorResult( error=( - f"database '{VIBRATION_DBNAME}' does not exist or is unreachable in " - "this environment; the data is unavailable, do not retry with other " - "arguments" + "the data source does not exist or is unreachable in this " + "environment; the data is unavailable, do not retry with other arguments" ) ) diff --git a/src/servers/vibration/tests/test_missing_database.py b/src/servers/vibration/tests/test_missing_database.py index de37c219d..b50431278 100644 --- a/src/servers/vibration/tests/test_missing_database.py +++ b/src/servers/vibration/tests/test_missing_database.py @@ -27,3 +27,4 @@ async def test_missing_database_reports_unavailable(mock_db): data = await call_tool(mcp, "list_vibration_sensors", _ARGS) assert "does not exist or is unreachable" in data["error"] + assert "vibration" not in data["error"] diff --git a/src/servers/wo/couch.py b/src/servers/wo/couch.py index ce32d40c8..4cd3f7a7b 100644 --- a/src/servers/wo/couch.py +++ b/src/servers/wo/couch.py @@ -56,7 +56,7 @@ def _raise_if_missing_db(self, r: "httpx.Response") -> None: return if reason == "Database does not exist.": raise CouchError( - f"database '{self.db}' does not exist in this environment; the " + "the data source does not exist in this environment; the " "data is unavailable, do not retry with other arguments" ) diff --git a/src/servers/wo/tests/test_missing_database.py b/src/servers/wo/tests/test_missing_database.py index a3539cbf3..cfc9d1108 100644 --- a/src/servers/wo/tests/test_missing_database.py +++ b/src/servers/wo/tests/test_missing_database.py @@ -28,7 +28,15 @@ async def test_missing_document_returns_none() -> None: async def test_missing_database_raises_distinct_message() -> None: client = _client("Database does not exist.") - with pytest.raises(CouchError, match="database 'workorder' does not exist"): + with pytest.raises(CouchError, match="data source does not exist"): await client.get("wo:MAIN:1") - with pytest.raises(CouchError, match="database 'workorder' does not exist"): + with pytest.raises(CouchError, match="data source does not exist"): await client.find({"type": "workorder"}) + + +@pytest.mark.anyio +async def test_missing_database_message_hides_database_name() -> None: + with pytest.raises(CouchError) as exc_info: + await _client("Database does not exist.").get("wo:MAIN:1") + + assert "workorder" not in str(exc_info.value) From 4224cff78a02d4d12f0012c8e490112350f85511 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Fri, 25 Sep 2026 16:18:05 -0400 Subject: [PATCH 3/3] fix: cover find_assets_by_sensors and wo delete in missing-database check Signed-off-by: Shuxin Lin --- src/servers/iot/main.py | 21 ++++++++++++++++----- src/servers/iot/tests/test_tools.py | 16 ++++++++++++++++ src/servers/wo/couch.py | 1 + 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/servers/iot/main.py b/src/servers/iot/main.py index 6566518dc..8c2b62926 100644 --- a/src/servers/iot/main.py +++ b/src/servers/iot/main.py @@ -497,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" @@ -541,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, @@ -726,8 +737,8 @@ def history( available_sensors = get_sensor_list(asset_id) if not available_sensors: return _missing_db_error(iot_db) or ErrorResult( - error=f"unknown asset_id {asset_id} or no sensors found" - ) + error=f"unknown asset_id {asset_id} or no sensors found" + ) unknown = [ sensor for sensor in selected_sensors if sensor not in available_sensors ] @@ -854,8 +865,8 @@ def latest_reading( available_sensors = get_sensor_list(asset_id) if not available_sensors: return _missing_db_error(iot_db) or ErrorResult( - error=f"unknown asset_id {asset_id} or no sensors found" - ) + 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}") diff --git a/src/servers/iot/tests/test_tools.py b/src/servers/iot/tests/test_tools.py index 632d4efdf..d71c21f92 100644 --- a/src/servers/iot/tests/test_tools.py +++ b/src/servers/iot/tests/test_tools.py @@ -1825,3 +1825,19 @@ async def test_missing_database_reports_unavailable( 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"] diff --git a/src/servers/wo/couch.py b/src/servers/wo/couch.py index 4cd3f7a7b..eac857b7b 100644 --- a/src/servers/wo/couch.py +++ b/src/servers/wo/couch.py @@ -81,6 +81,7 @@ async def put(self, doc: Dict[str, Any]) -> Dict[str, Any]: async def delete(self, doc_id: str, rev: str) -> Dict[str, Any]: r = await self._c.delete(f"/{self.db}/{doc_id}", params={"rev": rev}) + self._raise_if_missing_db(r) r.raise_for_status() return r.json()