fix(mcp): address final-review findings (sentinel exclusion, per-unit AMS data, generated_at, gating test, docs, cleanup)

This commit is contained in:
RNL
2026-08-19 22:43:11 +10:00
parent e23650eaa4
commit 86bab78a52
9 changed files with 142 additions and 21 deletions

View File

@@ -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.

View File

@@ -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):

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):