From 86bab78a529676c5add5fc0ac493613f9e8b8744 Mon Sep 17 00:00:00 2001 From: RNL Date: Wed, 19 Aug 2026 22:43:11 +1000 Subject: [PATCH] fix(mcp): address final-review findings (sentinel exclusion, per-unit AMS data, generated_at, gating test, docs, cleanup) --- README.md | 35 +++++++++++++++++++++++++++++ bambu_run/conf.py | 8 ------- bambu_run/mcp/filaments.py | 5 ++++- bambu_run/mcp/jobs.py | 14 ++++++++---- bambu_run/mcp/status.py | 37 +++++++++++++++++++++++++------ tests/test_mcp_filaments.py | 2 ++ tests/test_mcp_jobs.py | 17 ++++++++++++++ tests/test_mcp_status.py | 44 ++++++++++++++++++++++++++++++++++++- tests/test_mcp_toolkit.py | 1 + 9 files changed, 142 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a7dbb3a..f69a098 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ It runs quietly in the background 24/7, capturing every print, filament change, - [Troubleshooting (Native)](#troubleshooting-native) - [Docker Setup](#docker-setup) - [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types) +- [MCP Server (AI Agent Access)](#mcp-server-ai-agent-access) --- @@ -296,3 +297,37 @@ Processing: PLA Basic.txt → type='PLA' sub_type='PLA Basic' - **Created** — new color entries added to the database - **Skipped (duplicate)** — already existed, not changed - **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet + +## MCP Server (AI Agent Access) + +Bambu-Run exposes printer status, print history, and filament inventory as +read-only [MCP](https://modelcontextprotocol.io/) tools, so an AI agent (e.g. +Claude Desktop, Claude Code) can query your printer directly. + +Install the extra: + +```bash +pip install 'bambu-run[mcp]' +``` + +**stdio** (for a local agent on the same machine — e.g. Claude Desktop): + +```bash +python manage.py bambu_mcp_server --transport stdio +``` + +**HTTP** (for a remote agent, or multiple callers over the network): + +```bash +export BAMBU_RUN_MCP_API_KEY=your-secret-key +python manage.py bambu_mcp_server --transport http --host 0.0.0.0 --port 8808 +``` + +`BAMBU_RUN_MCP_API_KEY` is **required** for HTTP — the server refuses to start +without it, since an unauthenticated network MCP endpoint would expose your +printer and filament data to anyone who can reach the port. stdio needs no key +(the OS process boundary is the trust boundary there). + +If serving behind a reverse proxy on a real domain, pass +`--allowed-host your.domain.com` (repeatable) — otherwise the SDK's +DNS-rebinding guard rejects requests with a real `Host` header. diff --git a/bambu_run/conf.py b/bambu_run/conf.py index 10d62c3..ee4190e 100644 --- a/bambu_run/conf.py +++ b/bambu_run/conf.py @@ -64,14 +64,6 @@ class _Settings: def MCP_PORT(self): return get_setting("BAMBU_RUN_MCP_PORT", 8808) - @property - def MCP_AUTH_BACKEND(self): - return get_setting("BAMBU_RUN_MCP_AUTH_BACKEND", None) - - @property - def MCP_HIDE_SENSITIVE(self): - return get_setting("BAMBU_RUN_MCP_HIDE_SENSITIVE", False) - # Cloud sync settings @property def CLOUD_SYNC_ENABLED(self): diff --git a/bambu_run/mcp/filaments.py b/bambu_run/mcp/filaments.py index 4c916fe..92efe62 100644 --- a/bambu_run/mcp/filaments.py +++ b/bambu_run/mcp/filaments.py @@ -1,6 +1,8 @@ """list_filaments, get_filament -- the read_filament capability tools.""" from __future__ import annotations +from django.utils import timezone + from bambu_run.mcp.shapes import normalize_consumed, remaining_status from bambu_run.models import Filament @@ -44,7 +46,7 @@ def list_filaments( qs = qs.exclude(remaining_percent=-1).exclude(remaining_weight_grams__isnull=True).filter(remaining_weight_grams__gte=min_remaining_grams) spools = [_spool_summary(f, include_history) for f in qs.order_by("-remaining_percent")[:limit]] - return {"filaments": spools} + return {"generated_at": timezone.now().isoformat(), "filaments": spools} def _spool_summary(f, include_history): @@ -110,6 +112,7 @@ def get_filament(filament_id: int) -> dict: return { **summary, + "generated_at": timezone.now().isoformat(), "purchase_date": f.purchase_date.isoformat() if f.purchase_date else None, "usage_history": usages, } diff --git a/bambu_run/mcp/jobs.py b/bambu_run/mcp/jobs.py index d1de71c..b5b804b 100644 --- a/bambu_run/mcp/jobs.py +++ b/bambu_run/mcp/jobs.py @@ -31,10 +31,11 @@ def list_print_jobs( returned by one call; narrow days or increase limit deliberately. """ limit = min(limit, 100) + now = timezone.now() qs = PrintJob.objects.select_related("cloud_task", "device") if printer is not None: qs = qs.filter(device__in=resolve_printer(printer)) - since = timezone.now() - timedelta(days=days) + since = now - timedelta(days=days) qs = qs.filter(start_time__gte=since) if status == "running": qs = qs.filter(final_status__isnull=True) @@ -44,7 +45,7 @@ def list_print_jobs( qs = qs.filter(project_name__icontains=name_contains) jobs = list(qs.order_by("-start_time")[:limit]) - return {"jobs": [_job_summary(j) for j in jobs]} + return {"generated_at": now.isoformat(), "jobs": [_job_summary(j) for j in jobs]} def _job_summary(job): @@ -79,6 +80,7 @@ def get_print_job(job_id: int) -> dict: per-slot filament breakdown, estimate-vs-actual delta, and linked cloud metadata. Raises PrintJob.DoesNotExist if job_id is unknown. """ + now = timezone.now() job = PrintJob.objects.select_related("cloud_task", "device").get(id=job_id) summary = _job_summary(job) @@ -109,6 +111,7 @@ def get_print_job(job_id: int) -> dict: cloud = job.cloud_task return { **summary, + "generated_at": now.isoformat(), "estimate_delta_percent": delta_percent, "filament_usages": usages, "cloud_design_title": cloud.design_title if cloud else None, @@ -125,7 +128,8 @@ def get_print_statistics(days: int = 30, printer: str | int | None = None) -> di most_used_material answers "what did I print most" directly for the requested window (days=7 for last week, days=30 for last month). """ - since = timezone.now() - timedelta(days=days) + now = timezone.now() + since = now - timedelta(days=days) qs = PrintJob.objects.filter(start_time__gte=since) if printer is not None: qs = qs.filter(device__in=resolve_printer(printer)) @@ -142,7 +146,7 @@ def get_print_statistics(days: int = 30, printer: str | int | None = None) -> di cloud_qs = BambuCloudTask.objects.filter(print_jobs__in=qs).distinct() total_grams = cloud_qs.aggregate(s=Sum("weight_grams"))["s"] - jobs_without_cloud = total_jobs - cloud_qs.count() + jobs_without_cloud = qs.filter(cloud_task__isnull=True).count() # Same exclusion rules as normalize_consumed (starting_percent must be a # real reading, and consumption must be non-negative), expressed as a DB @@ -150,6 +154,7 @@ def get_print_statistics(days: int = 30, printer: str | int | None = None) -> di valid_usage = ( Q(starting_percent__gt=F("ending_percent")) & ~Q(starting_percent=-1) + & ~Q(ending_percent=-1) & Q(filament__initial_weight_grams__isnull=False) ) material_rows = ( @@ -186,6 +191,7 @@ def get_print_statistics(days: int = 30, printer: str | int | None = None) -> di median_ratio = statistics.median(ratios) if ratios else None return { + "generated_at": now.isoformat(), "window_days": days, "total_jobs": total_jobs, "success_rate": success_rate, diff --git a/bambu_run/mcp/status.py b/bambu_run/mcp/status.py index f7e6424..7dab82a 100644 --- a/bambu_run/mcp/status.py +++ b/bambu_run/mcp/status.py @@ -89,9 +89,25 @@ def _temperatures(metric): } +def _safe_tray_id(value, default=-1): + """Coerce a tray_id from the filaments JSON to int, defaulting on anything + that isn't a clean int (missing, None, or malformed) rather than crashing + the whole tool from one bad row.""" + try: + return int(value) + except (TypeError, ValueError): + return default + + def _ams_units(printer, latest_metric): """Build ams_units from the latest metric's filaments, with remain_percent - smoothed against the printer's 5 most recent metric rows.""" + smoothed against the printer's 5 most recent metric rows. + + Per-unit humidity/temp come from PrinterMetrics.ams_units (a JSONField with + one entry per physical AMS unit) -- NOT from PrinterMetrics.ams_humidity_raw/ + .ams_temp, which the collector only ever populates from unit 0's reading and + would otherwise stamp one unit's humidity onto every unit. + """ recent_filament_lists = list( PrinterMetrics.objects.filter(device=printer) .order_by("-timestamp") @@ -100,19 +116,24 @@ def _ams_units(printer, latest_metric): readings = defaultdict(list) for filaments in recent_filament_lists: for f in filaments or []: - key = (f.get("ams_unit_id"), int(f.get("tray_id", -1))) + key = (f.get("ams_unit_id"), _safe_tray_id(f.get("tray_id"))) readings[key].append(f.get("remain_percent")) + unit_readings = { + u.get("unit_id"): u for u in (latest_metric.ams_units or []) + } + units = {} for f in latest_metric.filaments or []: unit_id = f.get("ams_unit_id") - tray_id = int(f.get("tray_id", -1)) + tray_id = _safe_tray_id(f.get("tray_id")) value, status = smoothed_remaining_percent(readings[(unit_id, tray_id)]) + unit_reading = unit_readings.get(unit_id, {}) unit = units.setdefault(unit_id, { "unit_id": unit_id, "type": f.get("ams_type", ""), - "humidity_raw": latest_metric.ams_humidity_raw, - "temp_c": float(latest_metric.ams_temp) if latest_metric.ams_temp is not None else None, + "humidity_raw": unit_reading.get("humidity_raw"), + "temp_c": float(unit_reading["temp"]) if unit_reading.get("temp") is not None else None, "slots": [], }) color = f.get("color") @@ -156,12 +177,13 @@ def get_metrics_history( """ from bambu_run.views import sample_metrics # reuse: same thinning the dashboard already uses + now = timezone.now() max_points = min(max_points, 500) target = resolve_printer(printer).first() if target is None: - return {"printer": None, "timestamps": [], "series": {}} + return {"generated_at": now.isoformat(), "printer": None, "timestamps": [], "series": {}} - since = timezone.now() - timedelta(hours=hours) + since = now - timedelta(hours=hours) rows = list( PrinterMetrics.objects.filter(device=target, timestamp__gte=since).order_by("timestamp") ) @@ -181,6 +203,7 @@ def get_metrics_history( series["cooling_fan_percent"] = [r.cooling_fan_speed for r in rows] return { + "generated_at": now.isoformat(), "printer": {"id": target.id, "name": target.name}, "timestamps": [r.timestamp.isoformat() for r in rows], "series": series, diff --git a/tests/test_mcp_filaments.py b/tests/test_mcp_filaments.py index a21b87d..91d7b5d 100644 --- a/tests/test_mcp_filaments.py +++ b/tests/test_mcp_filaments.py @@ -15,6 +15,7 @@ def test_list_filaments_filters_material_sub_type_color(): assert len(result["filaments"]) == 1 assert result["filaments"][0]["sub_type"] == "PLA Matte" + assert "generated_at" in result @pytest.mark.django_db @@ -117,6 +118,7 @@ def test_get_filament_returns_full_usage_history_with_data_quality(): assert reliable["consumed_grams"] == 100 assert unreliable["data_quality"] == "unreliable" assert unreliable["consumed_grams"] is None + assert "generated_at" in result @pytest.mark.django_db diff --git a/tests/test_mcp_jobs.py b/tests/test_mcp_jobs.py index d95e3e0..e301eeb 100644 --- a/tests/test_mcp_jobs.py +++ b/tests/test_mcp_jobs.py @@ -16,6 +16,7 @@ def test_list_print_jobs_orders_most_recent_first(): result = list_print_jobs() assert [j["id"] for j in result["jobs"]] == [newer.id, older.id] + assert "generated_at" in result @pytest.mark.django_db @@ -86,6 +87,7 @@ def test_get_print_job_marks_negative_consumption_unreliable(): usage = result["filament_usages"][0] assert usage["consumed_grams"] is None assert usage["data_quality"] == "unreliable" + assert "generated_at" in result @pytest.mark.django_db @@ -121,6 +123,21 @@ def test_get_print_statistics_success_rate_and_status_counts(): assert result["success_rate"] == 0.5 assert result["status_counts"]["FINISH"] == 1 assert result["status_counts"]["FAILED"] == 1 + assert "generated_at" in result + + +@pytest.mark.django_db +def test_get_print_statistics_excludes_ending_sentinel_from_material_mix(): + p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True) + job = PrintJob.objects.create(device=p, project_name="A", start_time=timezone.now(), final_status="FINISH") + pla = Filament.objects.create(type="PLA", brand="Bambu Lab", color="Black", initial_weight_grams=1000) + # ending_percent=-1 (no RFID read at job end) must not be counted as 610g of consumption + FilamentUsage.objects.create(print_job=job, filament=pla, tray_id=0, starting_percent=60, ending_percent=-1) + + result = get_print_statistics(days=30) + + assert result["material_mix_grams"] == {} + assert result["most_used_material"] is None @pytest.mark.django_db diff --git a/tests/test_mcp_status.py b/tests/test_mcp_status.py index 524dd7b..a9c6515 100644 --- a/tests/test_mcp_status.py +++ b/tests/test_mcp_status.py @@ -97,6 +97,44 @@ def test_get_printer_status_ams_slot_unread_sentinel(): assert slot["remain_status"] == "unread" +@pytest.mark.django_db +def test_get_printer_status_ams_units_have_independent_humidity(): + p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True) + PrinterMetrics.objects.create( + device=p, timestamp=timezone.now(), gcode_state="RUNNING", + ams_humidity_raw=4, # unit-0-only flat column, must NOT be used for unit 1 + ams_units=[ + {"unit_id": 0, "ams_type": "AMS 2 Pro", "humidity_raw": 4, "temp": 28.0}, + {"unit_id": 1, "ams_type": "AMS", "humidity_raw": 7, "temp": 24.0}, + ], + filaments=[ + {"ams_unit_id": 0, "tray_id": "0", "type": "ABS", "remain_percent": 50}, + {"ams_unit_id": 1, "tray_id": "0", "type": "PLA", "remain_percent": 60}, + ], + ) + + result = get_printer_status(printer=p.id) + + units_by_id = {u["unit_id"]: u for u in result["printers"][0]["ams_units"]} + assert units_by_id[0]["humidity_raw"] == 4 + assert units_by_id[1]["humidity_raw"] == 7 + assert units_by_id[0]["temp_c"] == 28.0 + assert units_by_id[1]["temp_c"] == 24.0 + + +@pytest.mark.django_db +def test_get_printer_status_tolerates_malformed_tray_id(): + p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True) + PrinterMetrics.objects.create( + device=p, timestamp=timezone.now(), gcode_state="RUNNING", + filaments=[{"ams_unit_id": 0, "tray_id": None, "type": "ABS", "remain_percent": 50}], + ) + + result = get_printer_status(printer=p.id) # must not raise + + assert result["printers"][0]["ams_units"][0]["slots"][0]["slot"] == -1 + + @pytest.mark.django_db def test_get_printer_status_never_exposes_serial_or_ip(): p = Printer.objects.create( @@ -151,6 +189,7 @@ def test_get_metrics_history_returns_series_within_window(): assert result["printer"]["id"] == p.id assert len(result["timestamps"]) == 3 assert 999.0 not in result["series"]["nozzle_c"] + assert "generated_at" in result @pytest.mark.django_db @@ -181,7 +220,10 @@ def test_get_metrics_history_hard_ceiling_500_points(): @pytest.mark.django_db def test_get_metrics_history_no_matching_printer_returns_empty_shape(): result = get_metrics_history(printer=None) - assert result == {"printer": None, "timestamps": [], "series": {}} + assert result["printer"] is None + assert result["timestamps"] == [] + assert result["series"] == {} + assert "generated_at" in result from bambu_run.models import Hotend diff --git a/tests/test_mcp_toolkit.py b/tests/test_mcp_toolkit.py index 5a121e5..f283770 100644 --- a/tests/test_mcp_toolkit.py +++ b/tests/test_mcp_toolkit.py @@ -56,6 +56,7 @@ async def test_read_printer_only_caller_cannot_see_or_call_filament_tool(db): visible = await client.list_tools() visible_names = {t.name for t in visible.tools} assert "list_filaments" not in visible_names + assert "list_print_jobs" not in visible_names assert "get_printer_status" in visible_names with pytest.raises(MCPError):