diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 9955691f..9e5d1e91 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -128,7 +128,7 @@ Six FastMCP servers cover IoT data, time-series ML, work orders, vibration diagn | Server | Tools | Categories | Backing service | | ----------- | ----- | ------------------------ | -------------------------------------- | | `iot` | 4 | read | CouchDB | -| `utilities` | 3 | read | none | +| `utilities` | 5 | read | none | | `fmsr` | 2 | read, LLM-use | LiteLLM + `failure_modes.yaml` | | `wo` | 14 | read, write | CouchDB | | `tsfm` | 6 | read, write, cpu-centric | IBM Granite TinyTimeMixer (torch) | diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 726cb915..048ec299 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -36,13 +36,15 @@ Synthetic motor vibration data (`asset_id: Motor_01`, from `motor_01.json`) ship ## utilities — Utilities **Path:** `src/servers/utilities/main.py` -**Requires:** nothing (no external services) +**Requires:** CouchDB (`COUCHDB_URL`, `COUCHDB_USERNAME`, `COUCHDB_PASSWORD`, `FINAL_RESULT_DBNAME`) | Tool | Category | Arguments | Description | | ---------------------- | -------- | ----------- | ------------------------------------------------------ | | `json_reader` | read | `file_name` | Read and parse a JSON file from disk | | `current_date_time` | read | — | Return the current UTC date and time as JSON | | `current_time_english` | read | — | Return the current UTC time as a human-readable string | +| `write_final_result` | write | `result` | Persist the scenario's final answer to the final_result collection for grading. | +| `read_final_result` | read | - | Read back the stored final result (found=false if none written).| ## fmsr — Failure Mode and Sensor Relations diff --git a/src/couchdb/.allowed_datafiles b/src/couchdb/.allowed_datafiles index e4f4804d..ff0d0d68 100644 --- a/src/couchdb/.allowed_datafiles +++ b/src/couchdb/.allowed_datafiles @@ -9,3 +9,4 @@ src/couchdb/scenarios_data/shared/iot/hydraulic_pump_1.json src/couchdb/scenarios_data/shared/iot/metro_pump_1.json src/couchdb/scenarios_data/shared/iot/motor_01.json src/couchdb/scenarios_data/shared/work_order/workorders.csv +src/couchdb/scenarios_data/shared/final_result.json \ No newline at end of file diff --git a/src/couchdb/collections.json b/src/couchdb/collections.json index bac2576b..1ecbcf22 100644 --- a/src/couchdb/collections.json +++ b/src/couchdb/collections.json @@ -24,6 +24,11 @@ ] ] }, + "final_result": { + "format": "json", + "doctype": "result", + "indexes": [] + }, "iot": { "format": "json", "primary_key": [ diff --git a/src/couchdb/scenarios_data/default/manifest.json b/src/couchdb/scenarios_data/default/manifest.json index 06760289..2ec1984e 100644 --- a/src/couchdb/scenarios_data/default/manifest.json +++ b/src/couchdb/scenarios_data/default/manifest.json @@ -6,5 +6,6 @@ "shared/iot/hydraulic_pump_1.json" ], "vibration": "shared/iot/motor_01.json", - "failurecode": "shared/failure_code/failure_code_sample.csv" + "failurecode": "shared/failure_code/failure_code_sample.csv", + "final_result": "shared/final_result.json" } \ No newline at end of file diff --git a/src/couchdb/scenarios_data/shared/final_result.json b/src/couchdb/scenarios_data/shared/final_result.json new file mode 100644 index 00000000..9d08e1f0 --- /dev/null +++ b/src/couchdb/scenarios_data/shared/final_result.json @@ -0,0 +1,8 @@ +[ + { + "_id": "result", + "doctype": "result", + "result": null, + "written_at": null + } +] diff --git a/src/servers/utilities/main.py b/src/servers/utilities/main.py index 42858783..b2bc6263 100644 --- a/src/servers/utilities/main.py +++ b/src/servers/utilities/main.py @@ -4,8 +4,10 @@ from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 +from typing import Any, Dict, List, Union, Optional import pendulum +import couchdb3 from mcp.server.fastmcp import FastMCP from pydantic import BaseModel @@ -21,9 +23,31 @@ mcp = FastMCP( "utilities", - instructions="General utilities: read JSON files and get current date/time.", + instructions="General utilities: read JSON files, get current date/time, and write the " + "scenario's final result to CouchDB for the grader.", ) +# --- CouchDB (final-result store) --- +# init_data seeds an EMPTY `final_result` collection (one placeholder doc, id "result") at the start +# of each scenario run and rebuilds DBs from scratch, so the run is already isolated — no run id. +COUCHDB_URL = os.environ.get("COUCHDB_URL") +COUCHDB_USERNAME = os.environ.get("COUCHDB_USERNAME") +COUCHDB_PASSWORD = os.environ.get("COUCHDB_PASSWORD") +FINAL_RESULT_DBNAME = os.environ.get("FINAL_RESULT_DBNAME", "final_result") +_RESULT_DOC_ID = "result" # fixed: one result document per scenario run + +try: + _result_db = couchdb3.Database( + FINAL_RESULT_DBNAME, + url=COUCHDB_URL, + user=COUCHDB_USERNAME, + password=COUCHDB_PASSWORD, + ) + logger.info("Connected to CouchDB: %s", FINAL_RESULT_DBNAME) +except Exception as e: # noqa: BLE001 + logger.error("Failed to connect to final_result DB: %s", e) + _result_db = None + class DateTimeResult(BaseModel): currentDateTime: str @@ -35,6 +59,23 @@ class TimeEnglishResult(BaseModel): iso: str +class ErrorResult(BaseModel): + error: str + + +class WriteResultResponse(BaseModel): + ok: bool + doc_id: str + message: str + + +class ReadResultResponse(BaseModel): + found: bool + result: Optional[Union[Dict[str, Any], List[Any]]] = None + written_at: Optional[str] = None + message: str + + # --- Helper Functions --- @@ -66,6 +107,85 @@ def json_reader(file_name: str) -> str: return json.dumps({"error": str(e)}) +# --- Final Result Tool --- + + +@mcp.tool(title="Write Final Result") +def write_final_result( + result: Union[Dict[str, Any], List[Any]], +) -> Union[WriteResultResponse, ErrorResult]: + """Persist this scenario's FINAL answer as a JSON payload to CouchDB so the grader can read it. + + Call this exactly once, at the end, with your final answer. `result` is the JSON the task + description asks for — a JSON object, or a list of JSON objects. There is no task id: the run is + scoped to a single scenario (init_data seeds an empty `final_result` collection per run), so it is + one document. Calling again overwrites it. + """ + if _result_db is None: + return ErrorResult(error="CouchDB not connected") + doc = { + "_id": _RESULT_DOC_ID, + "doctype": "result", + "result": result, + "written_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + try: + existing = _result_db.get(_RESULT_DOC_ID) # seeded placeholder after init_data + doc["_rev"] = existing["_rev"] + except Exception: # noqa: BLE001 + pass # not seeded yet -> create fresh + try: + _result_db.save(doc) + return WriteResultResponse( + ok=True, doc_id=_RESULT_DOC_ID, message="final result written" + ) + except Exception as e: # noqa: BLE001 + logger.error("write_final_result failed: %s", e) + return ErrorResult(error=str(e)) + + +def _get_final_result_doc(): + """Fetch the raw result document (or None). Shared by the read tool and the grader helper below.""" + if _result_db is None: + return None + try: + return _result_db.get(_RESULT_DOC_ID) + except Exception: # noqa: BLE001 + return None + + +def _get_final_result_payload(): + """Grader-side helper (importable by the offline grader): return just the persisted payload, or + None if nothing was written.""" + doc = _get_final_result_doc() + return doc.get("result") if doc else None + + +@mcp.tool(title="Read Final Result") +def read_final_result() -> Union[ReadResultResponse, ErrorResult]: + """Read back the scenario's final result payload (what write_final_result stored). Returns + found=false if nothing has been written yet (the seeded placeholder has result=null). + """ + if _result_db is None: + return ErrorResult(error="CouchDB not connected") + doc = _get_final_result_doc() + if doc is None: + return ReadResultResponse( + found=False, result=None, message="no result document yet" + ) + res = doc.get("result") + return ReadResultResponse( + found=res is not None, + result=res, + written_at=doc.get("written_at"), + message=( + "final result read" + if res is not None + else "placeholder only (no result written yet)" + ), + ) + + # --- Time Tools ---