|
| 1 | +"""Per-tool isolation smoke for the ARIA MCP server (issue #15 — M2.8). |
| 2 | +
|
| 3 | +Standalone script (not pytest-collected) that exercises every registered MCP |
| 4 | +tool **directly through the ``MCPClient`` singleton** on the canonical P-02 |
| 5 | +seed (cell_id=1). Failures here block M3 (KB Builder) and M4 (Sentinel + |
| 6 | +Investigator agent loops) — debugging an agent loop with a broken tool is |
| 7 | +hell. |
| 8 | +
|
| 9 | +Differs from ``tests/e2e/aria_mcp_smoke.py``: |
| 10 | + * That script uses raw ``mcp.client.streamable_http_client`` to validate the |
| 11 | + transport contract. |
| 12 | + * **This** script uses the ``aria_mcp.client.MCPClient`` wrapper that the |
| 13 | + Investigator/Sentinel agents will actually consume — so it catches bugs |
| 14 | + in the wrapper layer (schema cache, ``ToolCallResult`` shape, error |
| 15 | + semantics) on top of tool correctness. |
| 16 | +
|
| 17 | +Per-tool assertion rules (audit §3 of issue #15): |
| 18 | + * Reads (``get_*``, ``list_*``): ``is_error=False`` + at least one expected |
| 19 | + business key in the payload. |
| 20 | + * Writes (``update_equipment_kb``): round-trip — patch a leaf, read back, |
| 21 | + confirm the leaf is the new value, then **restore** the original value in |
| 22 | + ``finally`` so the script is idempotent and the demo seed stays clean. |
| 23 | +
|
| 24 | +Pre-requisites: |
| 25 | + docker compose up -d |
| 26 | + make db.seed.p02 # canonical KB |
| 27 | +
|
| 28 | +Run: |
| 29 | + make backend.smoke.tools |
| 30 | +or: |
| 31 | + cd backend && PYTHONPATH=. python tests/integration/aria_mcp/tools_p02_isolation.py |
| 32 | +""" |
| 33 | + |
| 34 | +from __future__ import annotations |
| 35 | + |
| 36 | +import asyncio |
| 37 | +import json |
| 38 | +import os |
| 39 | +import sys |
| 40 | +from datetime import datetime, timedelta, timezone |
| 41 | +from typing import Any |
| 42 | + |
| 43 | +# Allow ``python tests/integration/aria_mcp/tools_p02_isolation.py`` from the |
| 44 | +# backend root by injecting it onto sys.path when invoked directly. |
| 45 | +if __package__ is None or __package__ == "": |
| 46 | + _BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) |
| 47 | + if _BACKEND_ROOT not in sys.path: |
| 48 | + sys.path.insert(0, _BACKEND_ROOT) |
| 49 | + |
| 50 | +from aria_mcp.client import MCPClient, ToolCallResult, mcp_client # noqa: E402 |
| 51 | + |
| 52 | +CELL_ID = 1 # P-02 in the canonical seed (migration 006) |
| 53 | +EXPECTED_TOOLS: set[str] = { |
| 54 | + # M2.2 KPI |
| 55 | + "get_oee", |
| 56 | + "get_mtbf", |
| 57 | + "get_mttr", |
| 58 | + "get_downtime_events", |
| 59 | + # M2.3 signals |
| 60 | + "get_signal_trends", |
| 61 | + "get_signal_anomalies", |
| 62 | + "get_current_signals", |
| 63 | + # M2.4 human context + hierarchy |
| 64 | + "get_logbook_entries", |
| 65 | + "get_shift_assignments", |
| 66 | + "get_work_orders", |
| 67 | + "list_cells", |
| 68 | + # M2.5 KB |
| 69 | + "get_equipment_kb", |
| 70 | + "get_failure_history", |
| 71 | + "update_equipment_kb", |
| 72 | + # M2.6 production |
| 73 | + "get_quality_metrics", |
| 74 | + "get_production_stats", |
| 75 | +} |
| 76 | + |
| 77 | + |
| 78 | +def _iso(dt: datetime) -> str: |
| 79 | + return dt.isoformat() |
| 80 | + |
| 81 | + |
| 82 | +def _payload(result: ToolCallResult) -> Any: |
| 83 | + """Decode a tool result; flatten the FastMCP ``{"result": [...]}`` wrapper.""" |
| 84 | + if result.is_error: |
| 85 | + raise AssertionError(f"tool returned is_error=True: {result.content}") |
| 86 | + if not result.content: |
| 87 | + return None |
| 88 | + data = json.loads(result.content) |
| 89 | + if ( |
| 90 | + isinstance(data, dict) |
| 91 | + and set(data.keys()) == {"result"} |
| 92 | + and isinstance(data["result"], list) |
| 93 | + ): |
| 94 | + return data["result"] |
| 95 | + return data |
| 96 | + |
| 97 | + |
| 98 | +async def _check( |
| 99 | + client: MCPClient, |
| 100 | + name: str, |
| 101 | + args: dict[str, Any], |
| 102 | + expected_keys: set[str] | None = None, |
| 103 | + list_non_empty: bool = False, |
| 104 | +) -> Any: |
| 105 | + """Call a read tool, assert success + expected business keys.""" |
| 106 | + res = await client.call_tool(name, args) |
| 107 | + payload = _payload(res) |
| 108 | + if list_non_empty: |
| 109 | + assert isinstance(payload, list) and len(payload) > 0, ( |
| 110 | + f"{name}: expected non-empty list, got {type(payload).__name__} len=" |
| 111 | + f"{len(payload) if hasattr(payload, '__len__') else 'n/a'}" |
| 112 | + ) |
| 113 | + if expected_keys is not None: |
| 114 | + sample = payload[0] if isinstance(payload, list) else payload |
| 115 | + assert isinstance(sample, dict) and expected_keys <= set( |
| 116 | + sample |
| 117 | + ), f"{name}: missing keys {expected_keys - set(sample)} in payload sample {sample!r}" |
| 118 | + print(f"[OK] {name}") |
| 119 | + return payload |
| 120 | + |
| 121 | + |
| 122 | +async def main() -> int: |
| 123 | + end = datetime.now(timezone.utc) |
| 124 | + start = end - timedelta(hours=24) |
| 125 | + today = end.date() |
| 126 | + window = {"window_start": _iso(start), "window_end": _iso(end)} |
| 127 | + cells = {"cell_ids": [CELL_ID], **window} |
| 128 | + |
| 129 | + # Use a dedicated client so we never clobber the module singleton's cache |
| 130 | + # when invoked alongside other consumers in the same process. |
| 131 | + client = MCPClient(os.environ.get("ARIA_MCP_URL", mcp_client.url)) |
| 132 | + |
| 133 | + # ---- discovery ---- |
| 134 | + schemas = await client.get_tools_schema() |
| 135 | + names = {s["name"] for s in schemas} |
| 136 | + missing = EXPECTED_TOOLS - names |
| 137 | + assert not missing, f"missing tools on server: {missing}" |
| 138 | + print(f"[OK] tools discovered: {len(names)} (expected {len(EXPECTED_TOOLS)} present)") |
| 139 | + |
| 140 | + # ---- M2.2 KPI ---- |
| 141 | + await _check(client, "get_oee", cells, expected_keys={"oee", "availability"}) |
| 142 | + await _check(client, "get_mtbf", cells, expected_keys={"mtbf_seconds"}) |
| 143 | + await _check(client, "get_mttr", cells, expected_keys={"mttr_seconds"}) |
| 144 | + dt_ev = await _check(client, "get_downtime_events", cells, list_non_empty=False) |
| 145 | + assert isinstance( |
| 146 | + dt_ev, list |
| 147 | + ), f"get_downtime_events: expected list, got {type(dt_ev).__name__}" |
| 148 | + |
| 149 | + # ---- M2.3 signals ---- |
| 150 | + cur = await _check( |
| 151 | + client, |
| 152 | + "get_current_signals", |
| 153 | + {"cell_id": CELL_ID}, |
| 154 | + expected_keys={"signal_def_id", "raw_value", "display_name"}, |
| 155 | + list_non_empty=True, |
| 156 | + ) |
| 157 | + sig_ids = [r["signal_def_id"] for r in cur] |
| 158 | + await _check( |
| 159 | + client, |
| 160 | + "get_signal_trends", |
| 161 | + {"signal_def_ids": sig_ids, **window, "aggregation": "1h"}, |
| 162 | + list_non_empty=True, |
| 163 | + ) |
| 164 | + anom = await _check( |
| 165 | + client, |
| 166 | + "get_signal_anomalies", |
| 167 | + {"cell_id": CELL_ID, **window}, |
| 168 | + list_non_empty=True, # issue #69 regression guard |
| 169 | + ) |
| 170 | + assert len(anom) > 0, ( |
| 171 | + "REGRESSION: get_signal_anomalies returned 0 breaches on canonical P-02 KB. " |
| 172 | + "Likely KB drift — run `make db.seed.p02` and retry." |
| 173 | + ) |
| 174 | + |
| 175 | + # ---- M2.4 context + hierarchy ---- |
| 176 | + cells_all = await _check( |
| 177 | + client, "list_cells", {}, expected_keys={"id", "name"}, list_non_empty=True |
| 178 | + ) |
| 179 | + assert any(c["name"] == "P-02" for c in cells_all), "P-02 missing from list_cells" |
| 180 | + |
| 181 | + await _check( |
| 182 | + client, |
| 183 | + "get_logbook_entries", |
| 184 | + {"cell_id": CELL_ID, **window}, |
| 185 | + list_non_empty=True, |
| 186 | + ) |
| 187 | + await _check( |
| 188 | + client, |
| 189 | + "get_shift_assignments", |
| 190 | + { |
| 191 | + "cell_id": CELL_ID, |
| 192 | + "date_start": (today - timedelta(days=7)).isoformat(), |
| 193 | + "date_end": today.isoformat(), |
| 194 | + }, |
| 195 | + list_non_empty=True, |
| 196 | + ) |
| 197 | + await _check( |
| 198 | + client, |
| 199 | + "get_work_orders", |
| 200 | + {"cell_id": CELL_ID}, |
| 201 | + list_non_empty=True, |
| 202 | + ) |
| 203 | + await _check( |
| 204 | + client, |
| 205 | + "get_work_orders", |
| 206 | + {"cell_id": CELL_ID, "priority": "critical", "generated_by_agent": True}, |
| 207 | + list_non_empty=True, # audit §1.2 filters wired |
| 208 | + ) |
| 209 | + |
| 210 | + # ---- M2.5 KB ---- |
| 211 | + kb_before = await _check( |
| 212 | + client, |
| 213 | + "get_equipment_kb", |
| 214 | + {"cell_id": CELL_ID}, |
| 215 | + expected_keys={"structured_data", "confidence_score"}, |
| 216 | + ) |
| 217 | + assert isinstance( |
| 218 | + kb_before["structured_data"], dict |
| 219 | + ), "get_equipment_kb: structured_data must be a parsed dict (not raw asyncpg string)" |
| 220 | + await _check( |
| 221 | + client, |
| 222 | + "get_failure_history", |
| 223 | + {"cell_id": CELL_ID, "limit": 10}, |
| 224 | + list_non_empty=False, |
| 225 | + ) |
| 226 | + |
| 227 | + # Write tool — round-trip with restore in finally (audit §1 Option B-lite). |
| 228 | + # Tracker: post-M3 we should add a TEST-00 sentinel cell so writes never |
| 229 | + # touch the demo seed at all (audit §1 Option A). |
| 230 | + vib_before = ( |
| 231 | + kb_before["structured_data"].get("thresholds", {}).get("vibration_mm_s", {}).get("alert") |
| 232 | + ) |
| 233 | + assert vib_before is not None, "P-02 KB seed must have thresholds.vibration_mm_s.alert" |
| 234 | + new_alert = float(vib_before) + 0.1 |
| 235 | + try: |
| 236 | + patched = await _check( |
| 237 | + client, |
| 238 | + "update_equipment_kb", |
| 239 | + { |
| 240 | + "cell_id": CELL_ID, |
| 241 | + "structured_data_patch": {"thresholds": {"vibration_mm_s": {"alert": new_alert}}}, |
| 242 | + "source": "tools_p02_isolation", |
| 243 | + "calibrated_by": "isolation_smoke", |
| 244 | + }, |
| 245 | + expected_keys={"structured_data"}, |
| 246 | + ) |
| 247 | + sd = patched["structured_data"] |
| 248 | + assert ( |
| 249 | + sd["thresholds"]["vibration_mm_s"]["alert"] == new_alert |
| 250 | + ), "update_equipment_kb: leaf-level patch did not round-trip" |
| 251 | + log = sd.get("calibration_log") or [] |
| 252 | + assert ( |
| 253 | + log and log[-1]["calibrated_by"] == "isolation_smoke" |
| 254 | + ), "update_equipment_kb: calibration_log not appended" |
| 255 | + finally: |
| 256 | + # Restore — keeps the demo seed clean even if assertions above fail. |
| 257 | + restore = await client.call_tool( |
| 258 | + "update_equipment_kb", |
| 259 | + { |
| 260 | + "cell_id": CELL_ID, |
| 261 | + "structured_data_patch": { |
| 262 | + "thresholds": {"vibration_mm_s": {"alert": float(vib_before)}} |
| 263 | + }, |
| 264 | + "source": "tools_p02_isolation", |
| 265 | + "calibrated_by": "isolation_smoke", |
| 266 | + }, |
| 267 | + ) |
| 268 | + assert not restore.is_error, f"FAILED to restore P-02 KB: {restore.content}" |
| 269 | + |
| 270 | + # ---- M2.6 production ---- |
| 271 | + quality = await _check( |
| 272 | + client, |
| 273 | + "get_quality_metrics", |
| 274 | + cells, |
| 275 | + expected_keys={ |
| 276 | + "cell_id", |
| 277 | + "total_pieces", |
| 278 | + "good_pieces", |
| 279 | + "bad_pieces", |
| 280 | + "quality_rate", |
| 281 | + }, |
| 282 | + list_non_empty=True, |
| 283 | + ) |
| 284 | + assert quality[0]["total_pieces"] >= 0 |
| 285 | + await _check( |
| 286 | + client, |
| 287 | + "get_production_stats", |
| 288 | + cells, |
| 289 | + expected_keys={ |
| 290 | + "productive_seconds", |
| 291 | + "unplanned_stop_seconds", |
| 292 | + "planned_stop_seconds", |
| 293 | + "total_pieces", |
| 294 | + "good_pieces", |
| 295 | + "bad_pieces", |
| 296 | + }, |
| 297 | + ) |
| 298 | + |
| 299 | + # ---- error-path contract — bogus arg must surface as is_error=True, not raise ---- |
| 300 | + bogus = await client.call_tool( |
| 301 | + "get_oee", |
| 302 | + {"cell_ids": [CELL_ID], "window_start": "bogus", "window_end": _iso(end)}, |
| 303 | + ) |
| 304 | + assert ( |
| 305 | + bogus.is_error is True |
| 306 | + ), "MCPClient contract: tool-side validation errors must return is_error=True, not raise" |
| 307 | + print("[OK] error path: invalid args surface as is_error=True (no exception)") |
| 308 | + |
| 309 | + print(f"\nALL {len(EXPECTED_TOOLS)} TOOLS PASS ISOLATION SMOKE ON P-02 (cell_id={CELL_ID})") |
| 310 | + return 0 |
| 311 | + |
| 312 | + |
| 313 | +if __name__ == "__main__": |
| 314 | + sys.exit(asyncio.run(main())) |
0 commit comments