diff --git a/docs/tool_universe.md b/docs/tool_universe.md index 53702b00b..f0cbe44cc 100644 --- a/docs/tool_universe.md +++ b/docs/tool_universe.md @@ -41,3 +41,9 @@ uv run python examples/quickstart_tooluniverse.py Each server launches via `uv run -mcp-server` and inherits your environment. Override with `ToolUniverse(servers={...})` if you run outside `uv`. + +## Guided tour + +[`notebook/showcase_iot_mcp_tools.ipynb`](../notebook/showcase_iot_mcp_tools.ipynb) +walks through every tool on the IoT server using the contract above: discovery, +sites and assets, sensor inventory, telemetry, a plot, and a registered workflow. diff --git a/notebook/showcase_iot_mcp_tools.ipynb b/notebook/showcase_iot_mcp_tools.ipynb new file mode 100644 index 000000000..e51cafad7 --- /dev/null +++ b/notebook/showcase_iot_mcp_tools.ipynb @@ -0,0 +1,742 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "eaa9360d", + "metadata": {}, + "source": [ + "# IoT MCP Tools — a guided tour\n", + "\n", + "AssetOpsBench ships an **MCP server per industrial domain**. This notebook walks\n", + "through the **IoT server** end to end: every one of its 12 tools, in the order you\n", + "would actually reach for them, using the `ToolUniverse` client from `mcphub`.\n", + "\n", + "By the end you will have gone from *\"I know nothing about this plant\"* to a plotted\n", + "sensor trace and a reusable multi-tool workflow, without writing a line of MCP\n", + "protocol code.\n", + "\n", + "**What you need before starting** (see [`docs/data.md`](../docs/data.md)):\n", + "\n", + "```bash\n", + "uv sync # install dependencies\n", + "docker compose -f src/couchdb/docker-compose.yaml up -d # start CouchDB\n", + "cp .env.public .env # COUCHDB_* connection settings\n", + "```\n", + "\n", + "Verify the data layer is up before running anything below:\n", + "\n", + "```bash\n", + "curl -s -u admin:password http://localhost:5984/_all_dbs\n", + "# → [\"workorder\",\"iot\",\"asset\",\"vibration\", ...]\n", + "```\n", + "\n", + "If `iot` or `asset` is missing, load the default dataset from the host:\n", + "\n", + "```bash\n", + "uv run python src/couchdb/init_data.py\n", + "```\n", + "\n", + "> **Note:** this notebook talks to a CouchDB running on `localhost`, so it is meant\n", + "> to be run locally (`uv run jupyter lab`). It will not work on hosted Colab\n", + "> without first exposing a reachable CouchDB and pointing `COUCHDB_URL` at it." + ] + }, + { + "cell_type": "markdown", + "id": "f02b2160", + "metadata": {}, + "source": [ + "## 1. Connect\n", + "\n", + "`ToolUniverse` follows a three-step contract: **init → load → run**. `load_tools()`\n", + "launches each MCP server as a subprocess and discovers the tools it advertises, so\n", + "the first call takes a moment.\n", + "\n", + "Tools are namespaced `.`, e.g. `iot.sites`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d582dff", + "metadata": {}, + "outputs": [], + "source": [ + "from mcphub import ToolUniverse\n", + "\n", + "tu = ToolUniverse() # 1. init\n", + "n = tu.load_tools(servers=[\"iot\"]) # 2. load (connect + discover)\n", + "print(f\"loaded {n} tools\")" + ] + }, + { + "cell_type": "markdown", + "id": "594d34ce", + "metadata": {}, + "source": [ + "Everything below uses two small helpers: `show()` to pretty-print a tool result, and\n", + "`unwrap()` because most IoT tools nest their payload under a `result` key." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1801dbdb", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "\n", + "\n", + "def show(title, obj, limit=None):\n", + " print(f\"=== {title} ===\")\n", + " text = json.dumps(obj, indent=2, default=str)\n", + " if limit and len(text) > limit:\n", + " text = text[:limit] + \"\\n... (truncated)\"\n", + " print(text)\n", + "\n", + "\n", + "def unwrap(res):\n", + " \"\"\"Most IoT tools wrap their payload in {'result': ...}; a few do not.\"\"\"\n", + " return res.get(\"result\", res) if isinstance(res, dict) else res" + ] + }, + { + "cell_type": "markdown", + "id": "6eaf3b21", + "metadata": {}, + "source": [ + "## 2. Discover what is available\n", + "\n", + "Before calling anything, ask the server what it can do. Three discovery calls matter:\n", + "\n", + "| Call | Answers |\n", + "|---|---|\n", + "| `tu.list_tools(\"iot\")` | what tools exist |\n", + "| `tu.find_tools(\"...\")` | which tool matches an intent |\n", + "| `tu.tool_specification(name)` | what arguments a tool takes |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5fd95d0", + "metadata": {}, + "outputs": [], + "source": [ + "tools = tu.list_tools(\"iot\")\n", + "print(f\"{len(tools)} IoT tools:\")\n", + "for t in tools:\n", + " print(\" -\", t)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0035b88a", + "metadata": {}, + "outputs": [], + "source": [ + "# Keyword search across loaded tools — useful when you know the intent, not the name.\n", + "show(\"find 'sensor history'\", [t[\"name\"] for t in tu.find_tools(\"sensor history\")])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe767b4b", + "metadata": {}, + "outputs": [], + "source": [ + "# The full input schema for one tool, straight from the server.\n", + "show(\"spec: iot.history\", tu.tool_specification(\"iot.history\"))" + ] + }, + { + "cell_type": "markdown", + "id": "4db50aab", + "metadata": {}, + "source": [ + "## 3. Where is everything? (sites and assets)\n", + "\n", + "Start at the top of the hierarchy: **site → asset → sensor**.\n", + "\n", + "- `iot.sites` — every site in the registry\n", + "- `iot.asset_ids` — asset IDs at one site (just the names)\n", + "- `iot.assets` — the same assets with detail, optionally filtered by type\n", + "- `iot.asset_detail` — the full record for one asset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8b6b6829", + "metadata": {}, + "outputs": [], + "source": [ + "sites = unwrap(tu.run({\"name\": \"iot.sites\", \"arguments\": {}}))\n", + "show(\"iot.sites\", sites)\n", + "\n", + "SITE = sites[\"sites\"][0] # everything below uses the first site\n", + "print(\"\\nusing site:\", SITE)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32fca0c0", + "metadata": {}, + "outputs": [], + "source": [ + "show(\"iot.asset_ids\", unwrap(tu.run({\n", + " \"name\": \"iot.asset_ids\",\n", + " \"arguments\": {\"site_name\": SITE},\n", + "})))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87d3acef", + "metadata": {}, + "outputs": [], + "source": [ + "# `assets` returns records rather than bare IDs, and takes an optional type filter.\n", + "show(\"iot.assets (all)\", unwrap(tu.run({\n", + " \"name\": \"iot.assets\",\n", + " \"arguments\": {\"site_name\": SITE},\n", + "})))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e4d3e87", + "metadata": {}, + "outputs": [], + "source": [ + "show(\"iot.assets (assettype=CHILLER)\", unwrap(tu.run({\n", + " \"name\": \"iot.assets\",\n", + " \"arguments\": {\"site_name\": SITE, \"assettype\": \"CHILLER\"},\n", + "})))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d22998f", + "metadata": {}, + "outputs": [], + "source": [ + "# Pick the asset with the most sensors — it makes the rest of the tour more interesting.\n", + "all_assets = unwrap(tu.run({\"name\": \"iot.assets\", \"arguments\": {\"site_name\": SITE}}))[\"assets\"]\n", + "ASSET = max(all_assets, key=lambda a: a.get(\"n_sensors\", 0))[\"asset_id\"]\n", + "print(\"using asset:\", ASSET)\n", + "\n", + "show(\"iot.asset_detail\", unwrap(tu.run({\n", + " \"name\": \"iot.asset_detail\",\n", + " \"arguments\": {\"site_name\": SITE, \"asset_id\": ASSET},\n", + "})))" + ] + }, + { + "cell_type": "markdown", + "id": "6e170b94", + "metadata": {}, + "source": [ + "## 4. What is measured? (sensor inventory)\n", + "\n", + "Two different questions that are easy to confuse:\n", + "\n", + "- `iot.installed_sensors` — what the **registry says** is fitted to the asset\n", + "- `iot.measured_sensors` — what actually **appears in the telemetry**\n", + "\n", + "They can disagree, and that gap is often the interesting part: a sensor that is\n", + "installed but never reports is a data-collection problem, not a healthy asset." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e302eb8c", + "metadata": {}, + "outputs": [], + "source": [ + "def sensor_sets(site_name, asset_id):\n", + " installed = unwrap(tu.run({\n", + " \"name\": \"iot.installed_sensors\",\n", + " \"arguments\": {\"site_name\": site_name, \"asset_id\": asset_id},\n", + " })).get(\"sensors\", [])\n", + " measured = unwrap(tu.run({\n", + " \"name\": \"iot.measured_sensors\",\n", + " \"arguments\": {\"site_name\": site_name, \"asset_id\": asset_id},\n", + " })).get(\"sensors\", [])\n", + " return set(installed), set(measured)\n", + "\n", + "\n", + "# Sweep the whole site rather than one asset — the disagreements are the point.\n", + "print(f\"{'asset':<12} {'installed':>9} {'measured':>9} {'only installed':>15} {'only measured':>14}\")\n", + "for a in all_assets:\n", + " inst, meas = sensor_sets(SITE, a[\"asset_id\"])\n", + " print(f\"{a['asset_id']:<12} {len(inst):>9} {len(meas):>9} \"\n", + " f\"{len(inst - meas):>15} {len(meas - inst):>14}\")" + ] + }, + { + "cell_type": "markdown", + "id": "0a8c6fae", + "metadata": {}, + "source": [ + "Two different failure shapes show up here:\n", + "\n", + "- an asset where a sensor is **installed but never reports** — a data-collection gap\n", + "- an asset where the counts match but the **names** do not, so a sensor is present\n", + " in the registry under one name and in the telemetry under another\n", + "\n", + "Both matter before you trust an analysis. Drill into whichever asset shows a\n", + "non-zero column:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6ba04b5", + "metadata": {}, + "outputs": [], + "source": [ + "for a in all_assets:\n", + " inst, meas = sensor_sets(SITE, a[\"asset_id\"])\n", + " if inst - meas or meas - inst:\n", + " print(f\"--- {a['asset_id']}\")\n", + " for s in sorted(inst - meas):\n", + " print(\" registry only :\", s)\n", + " for s in sorted(meas - inst):\n", + " print(\" telemetry only:\", s)" + ] + }, + { + "cell_type": "markdown", + "id": "1fc212fb", + "metadata": {}, + "source": [ + "`iot.sensor_coverage` answers the same question quantitatively: per sensor, how many\n", + "non-null readings exist and over what period." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "07814d84", + "metadata": {}, + "outputs": [], + "source": [ + "coverage = unwrap(tu.run({\n", + " \"name\": \"iot.sensor_coverage\",\n", + " \"arguments\": {\"site_name\": SITE, \"asset_id\": ASSET},\n", + "}))\n", + "print(f\"docs scanned: {coverage['docs_scanned']}\\n\")\n", + "for s in coverage[\"sensors\"][:5]:\n", + " print(f\" {s['sensor'][:55]:<55} {s['non_null_count']:>6} readings\")\n", + "print(f\" ... {len(coverage['sensors'])} sensors total\")" + ] + }, + { + "cell_type": "markdown", + "id": "c7a30410", + "metadata": {}, + "source": [ + "### Searching the other way round: sensors → assets\n", + "\n", + "`iot.find_assets_by_sensors` inverts the lookup. Use it when you know the measurement\n", + "you care about but not which assets provide it.\n", + "\n", + "- `match=\"all\"` (default) requires every listed sensor; `match=\"any\"` requires one\n", + "- `substring=True` matches on a fragment, which matters here because sensor names are\n", + " prefixed with the asset name\n", + "- `source=\"measured\"` (default) searches telemetry; `\"installed\"` searches the registry" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9c3cd31c", + "metadata": {}, + "outputs": [], + "source": [ + "show(\"find_assets_by_sensors: anything with 'Temperature'\", unwrap(tu.run({\n", + " \"name\": \"iot.find_assets_by_sensors\",\n", + " \"arguments\": {\n", + " \"site_name\": SITE,\n", + " \"sensors\": [\"Temperature\"],\n", + " \"match\": \"any\",\n", + " \"substring\": True,\n", + " },\n", + "})))" + ] + }, + { + "cell_type": "markdown", + "id": "94bbabbc", + "metadata": {}, + "source": [ + "## 5. The data itself\n", + "\n", + "Four tools, from cheapest to most detailed:\n", + "\n", + "| Tool | Use it to |\n", + "|---|---|\n", + "| `iot.stream_extent` | check the time range and record count *before* pulling data |\n", + "| `iot.latest_reading` | get the most recent value for every sensor |\n", + "| `iot.sensor_stats` | min / max / mean / stddev per sensor |\n", + "| `iot.history` | the raw observations, paginated |\n", + "\n", + "Always call `stream_extent` first. It tells you whether a `history` call is going to\n", + "exceed the page limit, which saves you from an accidental full-stream pull." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "afce6aba", + "metadata": {}, + "outputs": [], + "source": [ + "extent = unwrap(tu.run({\n", + " \"name\": \"iot.stream_extent\",\n", + " \"arguments\": {\"site_name\": SITE, \"asset_id\": ASSET},\n", + "}))\n", + "show(\"iot.stream_extent\", extent)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "201b35a8", + "metadata": {}, + "outputs": [], + "source": [ + "latest = unwrap(tu.run({\n", + " \"name\": \"iot.latest_reading\",\n", + " \"arguments\": {\"site_name\": SITE, \"asset_id\": ASSET},\n", + "}))\n", + "print(\"as of\", latest[\"timestamp\"], \"\\n\")\n", + "for name, value in list(latest[\"values\"].items())[:6]:\n", + " print(f\" {name[:55]:<55} {value}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4810b38d", + "metadata": {}, + "outputs": [], + "source": [ + "stats = unwrap(tu.run({\n", + " \"name\": \"iot.sensor_stats\",\n", + " \"arguments\": {\"site_name\": SITE, \"asset_id\": ASSET},\n", + "}))\n", + "for s in stats[\"stats\"][:5]:\n", + " print(f\"{s['sensor'][:45]:<45} n={s['count']:<6} \"\n", + " f\"min={s['min']:>10.2f} max={s['max']:>10.2f} mean={s['mean']:>10.2f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "e94cc25b", + "metadata": {}, + "source": [ + "### Pulling history\n", + "\n", + "`iot.history` is paginated. Pass `limit` to bound a page and feed the returned\n", + "`cursor` back in to continue. Narrow the pull with `sensors=[...]` and a\n", + "`start`/`end` window rather than fetching everything and filtering client-side." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a76cea3", + "metadata": {}, + "outputs": [], + "source": [ + "SENSOR = stats[\"stats\"][0][\"sensor\"]\n", + "print(\"focus sensor:\", SENSOR)\n", + "\n", + "page = unwrap(tu.run({\n", + " \"name\": \"iot.history\",\n", + " \"arguments\": {\n", + " \"site_name\": SITE,\n", + " \"asset_id\": ASSET,\n", + " \"sensors\": [SENSOR],\n", + " \"limit\": 5,\n", + " },\n", + "}))\n", + "show(\"iot.history (first page)\", page, limit=1200)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c28a2ff", + "metadata": {}, + "outputs": [], + "source": [ + "# The page tells you whether more exists (`has_more`) and how to ask for it\n", + "# (`next_cursor`). Feed that cursor back in unchanged; it is opaque and bound\n", + "# to this exact query.\n", + "print(\"returned :\", page[\"returned\"])\n", + "print(\"has_more :\", page[\"has_more\"])\n", + "\n", + "if page[\"has_more\"]:\n", + " page2 = unwrap(tu.run({\n", + " \"name\": \"iot.history\",\n", + " \"arguments\": {\n", + " \"site_name\": SITE,\n", + " \"asset_id\": ASSET,\n", + " \"sensors\": [SENSOR],\n", + " \"limit\": 5,\n", + " \"cursor\": page[\"next_cursor\"],\n", + " },\n", + " }))\n", + " print(\"\\npage 1 timestamps:\", [o[\"timestamp\"] for o in page[\"observations\"]])\n", + " print(\"page 2 timestamps:\", [o[\"timestamp\"] for o in page2[\"observations\"]])\n", + "else:\n", + " print(\"the whole stream fitted in one page\")" + ] + }, + { + "cell_type": "markdown", + "id": "09b68f03", + "metadata": {}, + "source": [ + "## 6. Plot it\n", + "\n", + "Nothing MCP-specific here: pull a window of history and hand the observations to\n", + "pandas and matplotlib." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6df7de9c", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " import matplotlib.pyplot as plt\n", + "except ModuleNotFoundError:\n", + " %pip install -q matplotlib\n", + " import matplotlib.pyplot as plt\n", + "\n", + "import pandas as pd" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9800f3dd", + "metadata": {}, + "outputs": [], + "source": [ + "window = unwrap(tu.run({\n", + " \"name\": \"iot.history\",\n", + " \"arguments\": {\n", + " \"site_name\": SITE,\n", + " \"asset_id\": ASSET,\n", + " \"sensors\": [SENSOR],\n", + " \"start\": extent[\"start_time\"],\n", + " \"end\": extent[\"end_time\"],\n", + " \"limit\": 500,\n", + " },\n", + "}))\n", + "\n", + "df = pd.DataFrame(window[\"observations\"])\n", + "df[\"timestamp\"] = pd.to_datetime(df[\"timestamp\"])\n", + "df = df.set_index(\"timestamp\").sort_index()\n", + "print(df.shape)\n", + "df.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "804adfc9", + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(figsize=(11, 4))\n", + "df[SENSOR].plot(ax=ax, linewidth=0.9)\n", + "ax.set_title(f\"{ASSET} — {SENSOR}\")\n", + "ax.set_xlabel(\"\")\n", + "ax.set_ylabel(SENSOR.replace(f\"{ASSET} \", \"\"))\n", + "ax.grid(alpha=0.3)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "9aae58e9", + "metadata": {}, + "source": [ + "## 7. Chaining tools into a workflow\n", + "\n", + "Calling tools one at a time is fine interactively, but a real question (\"give me a\n", + "health snapshot of this asset\") is several calls stitched together. MCPHub lets you\n", + "register that as a **workflow** and then invoke it through the *same* `tu.run()`\n", + "entrypoint as any single tool.\n", + "\n", + "A workflow is just a function `fn(tu, **arguments)`. Register it at runtime with\n", + "`tu.register_workflow(...)`, or add it permanently by putting it in\n", + "`src/mcphub/workflows.py` and listing its name in `REGISTERED`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "86efd09c", + "metadata": {}, + "outputs": [], + "source": [ + "def asset_snapshot(tu, site_name, asset_id, top_n=5):\n", + " \"\"\"Registry detail + data extent + coverage gap, in one call.\"\"\"\n", + " detail = unwrap(tu.run({\n", + " \"name\": \"iot.asset_detail\",\n", + " \"arguments\": {\"site_name\": site_name, \"asset_id\": asset_id},\n", + " }))\n", + " extent = unwrap(tu.run({\n", + " \"name\": \"iot.stream_extent\",\n", + " \"arguments\": {\"site_name\": site_name, \"asset_id\": asset_id},\n", + " }))\n", + " installed = unwrap(tu.run({\n", + " \"name\": \"iot.installed_sensors\",\n", + " \"arguments\": {\"site_name\": site_name, \"asset_id\": asset_id},\n", + " }))[\"sensors\"]\n", + " measured = unwrap(tu.run({\n", + " \"name\": \"iot.measured_sensors\",\n", + " \"arguments\": {\"site_name\": site_name, \"asset_id\": asset_id},\n", + " }))[\"sensors\"]\n", + " stats = unwrap(tu.run({\n", + " \"name\": \"iot.sensor_stats\",\n", + " \"arguments\": {\"site_name\": site_name, \"asset_id\": asset_id},\n", + " }))[\"stats\"]\n", + "\n", + " noisiest = sorted(stats, key=lambda s: s.get(\"stddev\") or 0, reverse=True)[:top_n]\n", + " return {\n", + " \"asset\": f\"{detail['asset_id']} ({detail['assettype']}) @ {detail['site_name']}\",\n", + " \"status\": detail[\"status\"],\n", + " \"window\": f\"{extent['start_time']} → {extent['end_time']}\",\n", + " \"records\": extent[\"total_records\"],\n", + " \"sensors_installed\": len(installed),\n", + " \"sensors_measured\": len(measured),\n", + " \"never_reporting\": sorted(set(installed) - set(measured)),\n", + " \"most_variable\": [\n", + " {\"sensor\": s[\"sensor\"], \"stddev\": round(s[\"stddev\"], 2)} for s in noisiest\n", + " ],\n", + " }\n", + "\n", + "\n", + "tu.register_workflow(\"asset_snapshot\", asset_snapshot)\n", + "print(\"registered:\", \"asset_snapshot\" in tu.list_tools())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "07ba9ddb", + "metadata": {}, + "outputs": [], + "source": [ + "# Invoked exactly like a tool — same entrypoint, same argument shape.\n", + "show(\"workflow: asset_snapshot\", tu.run({\n", + " \"name\": \"asset_snapshot\",\n", + " \"arguments\": {\"site_name\": SITE, \"asset_id\": ASSET},\n", + "}))" + ] + }, + { + "cell_type": "markdown", + "id": "77a314cc", + "metadata": {}, + "source": [ + "Because a workflow takes `tu`, it can call **any** loaded server, not just IoT. Loading\n", + "`servers=[\"iot\", \"wo\", \"fmsr\"]` lets one workflow join telemetry to work orders and\n", + "failure modes, which is the basis of the multi-agent scenarios in the benchmark." + ] + }, + { + "cell_type": "markdown", + "id": "4a832cad", + "metadata": {}, + "source": [ + "## 8. Clean up\n", + "\n", + "`tu.close()` shuts down the MCP server subprocesses. In a notebook it is easy to forget\n", + "and leak them across restarts, so do it explicitly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33462b2b", + "metadata": {}, + "outputs": [], + "source": [ + "tu.close()\n", + "print(\"closed\")" + ] + }, + { + "cell_type": "markdown", + "id": "d7a865a9", + "metadata": {}, + "source": [ + "## Where to go next\n", + "\n", + "- [`docs/tool_universe.md`](../docs/tool_universe.md) — the full `ToolUniverse` contract\n", + "- [`docs/mcp-servers.md`](../docs/mcp-servers.md) — the other servers (FMSR, TSFM, WO, vibration)\n", + "- [`docs/data.md`](../docs/data.md) — loading scenario data instead of the default set\n", + "- [`examples/quickstart_tooluniverse.py`](../examples/quickstart_tooluniverse.py) — the same\n", + " three-step contract as a plain script\n", + "\n", + "**Tool reference — everything covered above**\n", + "\n", + "| Tool | Purpose |\n", + "|---|---|\n", + "| `iot.sites` | list all sites |\n", + "| `iot.asset_ids` | asset IDs at a site |\n", + "| `iot.assets` | asset records, optional `assettype` filter |\n", + "| `iot.asset_detail` | full record for one asset |\n", + "| `iot.installed_sensors` | sensors the registry says are fitted |\n", + "| `iot.measured_sensors` | sensors actually present in telemetry |\n", + "| `iot.find_assets_by_sensors` | reverse lookup: sensors → assets |\n", + "| `iot.sensor_coverage` | per-sensor non-null counts and time span |\n", + "| `iot.stream_extent` | time range, record count, page-limit warning |\n", + "| `iot.latest_reading` | most recent value per sensor |\n", + "| `iot.sensor_stats` | min / max / mean / stddev per sensor |\n", + "| `iot.history` | raw paginated observations |" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}