From d428fb8c0c09e65bf4c954a54def0615b33c0615 Mon Sep 17 00:00:00 2001 From: Abdullah Date: Thu, 18 Jun 2026 01:57:50 -0700 Subject: [PATCH] Add Mender Truffle app --- truffile/resources/app-store/mender/icon.png | Bin 0 -> 1865 bytes .../app-store/mender/mender_foreground.py | 185 ++++++++++++++++++ truffile/resources/app-store/mender/tools.py | 62 ++++++ .../resources/app-store/mender/truffile.yaml | 26 +++ 4 files changed, 273 insertions(+) create mode 100644 truffile/resources/app-store/mender/icon.png create mode 100644 truffile/resources/app-store/mender/mender_foreground.py create mode 100644 truffile/resources/app-store/mender/tools.py create mode 100644 truffile/resources/app-store/mender/truffile.yaml diff --git a/truffile/resources/app-store/mender/icon.png b/truffile/resources/app-store/mender/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..eed3379784aba679fbe74376caa5cc519827e605 GIT binary patch literal 1865 zcmc&#Sx}Q#6h40vL!cy~QZZ@(K_z2U5K+Mt!V+b1fru!mEJ2D&s4QX>shA%q0mT6o z9Vi7st>eNXD9BC{u?nKZY7qumf@p%WrPV-90%@pi$A>=j&8K_soICfP?|kR`&Iz`+ z2hm`@0RRxaJlFdIAi6CA1iWquJa}y{fS!@pdRM>r{L$Wl*xmLY9~JkbHB9sEIB@4#OW#!^_Ms4J(LYS%ZUyVhM=Cp(&LDTfdMfNlJx;+3}*qd`w_-&F&D;$sDf)tRaRz%I>5kzhgdRmj} zh&_w+k6^Ty{EpBac z^aitUTDZ5Jv%W<2C}mDvSJnEW0>h5rkC=a9gOQfxn>#UaiTn!KTK`+E$S^@(Y`esa1)}jB7>D z$|ah;`MkFqz*#_k@jDDNaocvcy( z$PzlZQC-o+cdR>Pdj;v>=X;=~xPmF?nxh-%*!N6cuk=1I+b5Vnpw9|u*WdfSNK!I9 zhQY74X`W2(x3iiax0}{+Fm=AV)uZ|YcP>beV^*FXezUIRorA9P zcg^CwmfQJUiTjW}6jo^f3d-M#;6S0avwZBqN5MR|f`aJ?fvWD&?jHfNjL{U0C3nRz z|J47JkjOQt$)s-CU-!$`B8n<@`>)}B-8hKPpLu{Hvbps9-@kO?EZGj4)*ltE!OKtT?HitZB($;J zLYGD&(1ZFNJI1y}6wn__3`eo-EmsE0T2%SVbmY>bT0SAy??9LTZL>%n|9c)2r^s_U zFTQMeWMquqpp&#eJnh$V|7l!@ZLgCy9eo>UJ#o>%9(ALepFHE+Cnvqcup7sE$sWo{ z|3s#HhMJ(*rgUuxOdwuXmMtB_muH)j9y`@FWM%G&hMczGl-j{(t*Ym9P0_aSn--0M z6BNJ5x-3a?2xQeV1JC1+RI?=H-|Tf!?SRt%OrihMOUno%{$cZ1k#=d_45cObGl-L$ zB!>0MA;~zn9b!ImlxVRvFRz~#GqsjrL&@wL8Q0Dz@~4lA6qmo>;~Z^_9--u!jSRlP zNwI=`(G%l|m>4})B~3kHi_~O?c4ti0Wr3rJJt}7Q`smD{=A#+VD7W%P+6Hk_FG1;y zUxeTpQu&h)mmV`n)3#B7rQBUpqK?I^Ob>O;QJOV-ph?FT=SsxpZA1=+#Yp3sN{F1< z)k(2b&CwpPvTJw{Agt{HZ|PcvgPxX(iLtc!BR7R_jRd$5g5wmOMri8yB5}wTFsNbT z^lf8?hKECIMG;&AJ+s*0dJs61B1X))7dWV4cgJPnL?tx4``Paih%i6jomj otd=W&H|wAEndifQ4ySTHqOqJaz6vFn>qaDaZSY=S=Eh0>6S^w?5dZ)H literal 0 HcmV?d00001 diff --git a/truffile/resources/app-store/mender/mender_foreground.py b/truffile/resources/app-store/mender/mender_foreground.py new file mode 100644 index 0000000..51364d8 --- /dev/null +++ b/truffile/resources/app-store/mender/mender_foreground.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import inspect +import json +import os +from typing import Any + +from mcp.types import CallToolResult, TextContent +from truffile.app_runtime import ForegroundApp, ToolSpec, phosphor_icon_url +from tools import TOOLS as _TOOL_SPECS, ToolDefinition + + +API_BASE = os.getenv("MENDER_API_BASE", "https://hosted.mender.io/api/management/v1").rstrip("/") +_TOKEN = os.getenv("MENDER_ACCESS_TOKEN", "").strip() + + +def _headers(): + return { + "Authorization": f"Bearer {_TOKEN}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + +def _make_tool_spec(entry: ToolDefinition) -> ToolSpec: + supported = set(inspect.signature(ToolSpec).parameters) + kwargs: dict[str, Any] = { + "name": entry.name, + "title": entry.title, + "description": entry.description, + "icon": phosphor_icon_url(entry.icon), + "annotations": dict(entry.annotations), + } + if "structured_output" in supported: + kwargs["structured_output"] = False + if "readonly" in supported: + kwargs["readonly"] = bool(entry.annotations.get("readOnlyHint")) + return ToolSpec(**kwargs) + + +def _text_result( + text: str, *, structured: dict[str, Any] | None = None, is_error: bool = False +) -> CallToolResult: + return CallToolResult( + content=[TextContent(type="text", text=text.strip())], + structuredContent=structured, + isError=is_error, + ) + + +async def _http_get(path: str) -> dict | list: + import httpx + + url = f"{API_BASE}{path}" + with httpx.Client(timeout=30) as client: + resp = client.get(url, headers=_headers()) + resp.raise_for_status() + return resp.json() + + +async def _http_post(path: str, data: dict) -> dict: + import httpx + + url = f"{API_BASE}{path}" + with httpx.Client(timeout=30) as client: + resp = client.post(url, headers=_headers(), json=data) + resp.raise_for_status() + return resp.json() + + +async def _format_device_list(devices: list) -> CallToolResult: + lines = [f"### Mender devices", f"{len(devices)} devices found."] + refs: list[dict[str, Any]] = [] + for i, dev in enumerate(devices[:50], start=1): + if not isinstance(dev, dict): + continue + did = dev.get("id", "") + attrs = {a["name"]: a.get("value") for a in dev.get("attributes", []) if isinstance(a, dict)} + status = dev.get("status", "unknown") + name = attrs.get("name", "unnamed") + host = attrs.get("hostname", attrs.get("mac", "")) + artifact = attrs.get("artifact_name", "") + lines.append( + f"- [{i}] {name or did[:8]} | status={status} | id={did} | artifact={artifact}" + ) + refs.append({"ref": i, "device_id": did, "name": name, "status": status}) + structured = {"devices": refs, "total": len(devices)} + return _text_result("\n".join(lines), structured=structured) + + +async def _format_status_result(tool: str, *, message: str, is_error: bool = False) -> CallToolResult: + return _text_result(f"Mender {tool}: {message}", is_error=is_error) + + +class MenderForegroundApp(ForegroundApp): + def __init__(self) -> None: + super().__init__("mender", logger_name="mender.foreground") + self._register_tools() + + def _register_tools(self) -> None: + specs = {e.name: _make_tool_spec(e) for e in _TOOL_SPECS} + + @self.tool(specs["list_devices"]) + async def list_devices_tool() -> CallToolResult: + try: + devices = await _http_get("/inventory/devices") + return await _format_device_list(devices) + except Exception as exc: + return await _format_status_result("list_devices", message=str(exc), is_error=True) + + @self.tool(specs["get_device"]) + async def get_device_tool(device_id: str) -> CallToolResult: + try: + dev = await _http_get(f"/inventory/devices/{device_id}") + return await _format_device_list([dev]) + except Exception as exc: + return await _format_status_result("get_device", message=str(exc), is_error=True) + + @self.tool(specs["accept_device"]) + async def accept_device_tool(device_id: str) -> CallToolResult: + try: + await _http_post( + f"/inventory/devices/{device_id}/status", {"status": "accepted"} + ) + return await _format_status_result("accept_device", message=f"Device {device_id} accepted") + except Exception as exc: + return await _format_status_result("accept_device", message=str(exc), is_error=True) + + @self.tool(specs["list_deployments"]) + async def list_deployments_tool() -> CallToolResult: + try: + deps = await _http_get("/deployments") + lines = [f"### Mender deployments", f"{len(deps)} deployments found."] + for i, dep in enumerate(deps[:50], start=1): + if not isinstance(dep, dict): + continue + did = dep.get("id", "") + state = dep.get("device_total", 0) + created = dep.get("created_ts", "") + lines.append(f"- [{i}] id={did[:8]}... devices={state} created={created}") + return _text_result("\n".join(lines), structured={"deployments": deps[:50], "total": len(deps)}) + except Exception as exc: + return await _format_status_result("list_deployments", message=str(exc), is_error=True) + + @self.tool(specs["get_deployment"]) + async def get_deployment_tool(deployment_id: str) -> CallToolResult: + try: + dep = await _http_get(f"/deployments/{deployment_id}") + return _text_result( + json.dumps(dep, indent=2, default=str), + structured={"deployment": dep}, + ) + except Exception as exc: + return await _format_status_result("get_deployment", message=str(exc), is_error=True) + + @self.tool(specs["list_releases"]) + async def list_releases_tool() -> CallToolResult: + try: + releases = await _http_get("/releases") + lines = [f"### Mender releases", f"{len(releases)} releases found."] + for i, rel in enumerate(releases[:50], start=1): + if not isinstance(rel, dict): + continue + name = rel.get("name", "unnamed") + mods = rel.get("artifact", {}).get("source", {}).get("meta", {}) + lines.append(f"- [{i}] {name} | source={mods.get('source', 'unknown')}") + return _text_result("\n".join(lines), structured={"releases": releases[:50], "total": len(releases)}) + except Exception as exc: + return await _format_status_result("list_releases", message=str(exc), is_error=True) + + # Clean up metadata so the model doesn't try to parse output schemas + for entry in _TOOL_SPECS: + tool = self._mcp._tool_manager.get_tool(entry.name) + metadata = getattr(tool, "fn_metadata", None) + if metadata is not None: + metadata.output_schema = None + metadata.output_model = None + metadata.wrap_output = False + + +app = MenderForegroundApp() + +if __name__ == "__main__": + app.run() \ No newline at end of file diff --git a/truffile/resources/app-store/mender/tools.py b/truffile/resources/app-store/mender/tools.py new file mode 100644 index 0000000..01f868d --- /dev/null +++ b/truffile/resources/app-store/mender/tools.py @@ -0,0 +1,62 @@ +"""Canonical foreground tool metadata for Mender IoT.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ToolDefinition: + name: str + title: str + description: str + icon: str + annotations: dict[str, bool] + + +TOOLS = [ + ToolDefinition( + name="list_devices", + title="List Devices", + description="List all Mender devices connected to your tenant. Returns device IDs, attributes, status, and last check-in time.", + icon="device-desktop", + annotations={"readOnlyHint": True, "destructiveHint": False}, + ), + ToolDefinition( + name="get_device", + title="Get Device", + description="Get full details for a specific Mender device by ID, including inventory attributes and status.", + icon="device-desktop", + annotations={"readOnlyHint": True, "destructiveHint": False}, + ), + ToolDefinition( + name="accept_device", + title="Accept Device", + description="Accept a pending Mender device so it can receive deployments.", + icon="circle-wrench", + annotations={"readOnlyHint": False, "destructiveHint": False}, + ), + ToolDefinition( + name="list_deployments", + title="List Deployments", + description="List recent Mender deployments with status, device count, and created time.", + icon="cloud-arrow-up", + annotations={"readOnlyHint": True, "destructiveHint": False}, + ), + ToolDefinition( + name="get_deployment", + title="Get Deployment", + description="Get details for a specific deployment by ID, including devices and artifacts.", + icon="file-text", + annotations={"readOnlyHint": True, "destructiveHint": False}, + ), + ToolDefinition( + name="list_releases", + title="List Releases", + description="List all software releases/artifacts in Mender.", + icon="package", + annotations={"readOnlyHint": True, "destructiveHint": False}, + ), +] + +TOOLS_BY_NAME = {tool.name: tool for tool in TOOLS} \ No newline at end of file diff --git a/truffile/resources/app-store/mender/truffile.yaml b/truffile/resources/app-store/mender/truffile.yaml new file mode 100644 index 0000000..6d4a852 --- /dev/null +++ b/truffile/resources/app-store/mender/truffile.yaml @@ -0,0 +1,26 @@ +truffile_version: 2 +metadata: + name: Mender + bundle_id: org.deepshard.mender + description: Mender IoT — device management and deployments + icon_file: ./icon.png + foreground: + process: + cmd: + - python + - mender_foreground.py + working_directory: / + environment: + PYTHONUNBUFFERED: "1" + +steps: + - name: Copy app files + type: files + update_policy: run_on_update + files: + - source: ./mender_foreground.py + destination: ./mender_foreground.py + - source: ./tools.py + destination: ./tools.py + - source: ./icon.png + destination: ./icon.png \ No newline at end of file