pycentauri 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pycentauri/client.py ADDED
@@ -0,0 +1,328 @@
1
+ """High-level async client for the Elegoo Centauri Carbon.
2
+
3
+ Opens a single WebSocket to ``ws://<host>:3030/websocket``, routes responses
4
+ back to the requesting coroutines by ``RequestID``, and publishes status /
5
+ attribute pushes to any number of subscribers. Control actions are gated
6
+ behind ``enable_control=True`` — attempting a write without it raises
7
+ :class:`ControlDisabledError` before anything is sent over the wire.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import logging
15
+ from collections.abc import AsyncIterator
16
+ from types import TracebackType
17
+ from typing import Any
18
+
19
+ import websockets
20
+ from typing_extensions import Self
21
+ from websockets.asyncio.client import ClientConnection, connect
22
+
23
+ from pycentauri import camera as camera_module
24
+ from pycentauri import sdcp
25
+ from pycentauri.models import Attributes, Status
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ WS_PORT = 3030
31
+ WS_PATH = "/websocket"
32
+ DEFAULT_CONNECT_TIMEOUT = 10.0
33
+ DEFAULT_REQUEST_TIMEOUT = 15.0
34
+ DEFAULT_PUSH_PERIOD_MS = sdcp.DEFAULT_PUSH_PERIOD_MS
35
+
36
+
37
+ class PrinterError(RuntimeError):
38
+ """Base for all client-side printer errors."""
39
+
40
+
41
+ class ControlDisabledError(PrinterError):
42
+ """Raised when a control action is attempted without ``enable_control``."""
43
+
44
+
45
+ class RequestTimeoutError(PrinterError):
46
+ """Raised when a request to the printer does not receive a response in time."""
47
+
48
+
49
+ class Printer:
50
+ """Async client for a single Centauri Carbon.
51
+
52
+ Usage::
53
+
54
+ async with await Printer.connect("192.168.1.209") as printer:
55
+ status = await printer.status()
56
+
57
+ Or without the context manager::
58
+
59
+ printer = await Printer.connect("192.168.1.209")
60
+ try:
61
+ ...
62
+ finally:
63
+ await printer.close()
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ host: str,
69
+ *,
70
+ enable_control: bool = False,
71
+ push_period_ms: int = DEFAULT_PUSH_PERIOD_MS,
72
+ ) -> None:
73
+ self.host = host
74
+ self.enable_control = enable_control
75
+ self.push_period_ms = push_period_ms
76
+
77
+ self._ws: ClientConnection | None = None
78
+ self._reader: asyncio.Task[None] | None = None
79
+ self._mainboard_id: str | None = None
80
+
81
+ self._mainboard_event = asyncio.Event()
82
+ self._latest_status: Status | None = None
83
+ self._latest_status_event = asyncio.Event()
84
+ self._latest_attributes: Attributes | None = None
85
+ self._latest_attributes_event = asyncio.Event()
86
+
87
+ self._pending: dict[str, asyncio.Future[sdcp.ParsedMessage]] = {}
88
+ self._status_queues: set[asyncio.Queue[Status]] = set()
89
+ self._closed = False
90
+
91
+ @classmethod
92
+ async def connect(
93
+ cls,
94
+ host: str,
95
+ *,
96
+ enable_control: bool = False,
97
+ push_period_ms: int = DEFAULT_PUSH_PERIOD_MS,
98
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
99
+ ) -> Self:
100
+ """Open a WebSocket to the printer and start the reader task."""
101
+ self = cls(host, enable_control=enable_control, push_period_ms=push_period_ms)
102
+ url = f"ws://{host}:{WS_PORT}{WS_PATH}"
103
+ self._ws = await asyncio.wait_for(connect(url, max_size=None), timeout=connect_timeout)
104
+ self._reader = asyncio.create_task(self._read_loop(), name=f"pycentauri-reader-{host}")
105
+ return self
106
+
107
+ # --- context-manager sugar -------------------------------------------------
108
+
109
+ async def __aenter__(self) -> Self:
110
+ return self
111
+
112
+ async def __aexit__(
113
+ self,
114
+ exc_type: type[BaseException] | None,
115
+ exc: BaseException | None,
116
+ tb: TracebackType | None,
117
+ ) -> None:
118
+ await self.close()
119
+
120
+ # --- public high-level API -------------------------------------------------
121
+
122
+ @property
123
+ def mainboard_id(self) -> str | None:
124
+ """Mainboard ID learned from the printer (set once the first Attributes push arrives)."""
125
+ return self._mainboard_id
126
+
127
+ async def wait_for_mainboard(self, timeout: float = 5.0) -> str:
128
+ """Block until the printer has reported its mainboard ID."""
129
+ if self._mainboard_id:
130
+ return self._mainboard_id
131
+ # The printer sends an Attributes push shortly after connect; a fresh
132
+ # GET_PRINTER_ATTRIBUTES also triggers one. Fire one off just in case.
133
+ with contextlib.suppress(Exception):
134
+ await self._send_raw(
135
+ sdcp.encode(
136
+ sdcp.build_request(
137
+ sdcp.Cmd.GET_PRINTER_ATTRIBUTES, None, self._mainboard_id or ""
138
+ )
139
+ )
140
+ )
141
+ await asyncio.wait_for(self._mainboard_event.wait(), timeout=timeout)
142
+ assert self._mainboard_id is not None
143
+ return self._mainboard_id
144
+
145
+ async def status(self, timeout: float = DEFAULT_REQUEST_TIMEOUT) -> Status:
146
+ """Get the current printer status.
147
+
148
+ If we've already received a push, return it immediately. Otherwise
149
+ subscribe briefly (Cmd 512) and wait for the first push.
150
+ """
151
+ if self._latest_status is not None:
152
+ return self._latest_status
153
+ await self._ensure_subscribed()
154
+ await asyncio.wait_for(self._latest_status_event.wait(), timeout=timeout)
155
+ assert self._latest_status is not None
156
+ return self._latest_status
157
+
158
+ async def attributes(self, timeout: float = DEFAULT_REQUEST_TIMEOUT) -> Attributes:
159
+ """Return the printer's attributes (model, firmware, capabilities)."""
160
+ if self._latest_attributes is not None:
161
+ return self._latest_attributes
162
+ mid = await self.wait_for_mainboard(timeout=timeout)
163
+ await self._request(sdcp.Cmd.GET_PRINTER_ATTRIBUTES, None, mid, timeout=timeout)
164
+ await asyncio.wait_for(self._latest_attributes_event.wait(), timeout=timeout)
165
+ assert self._latest_attributes is not None
166
+ return self._latest_attributes
167
+
168
+ async def watch(self) -> AsyncIterator[Status]:
169
+ """Yield status updates as they arrive from the printer."""
170
+ await self._ensure_subscribed()
171
+ queue: asyncio.Queue[Status] = asyncio.Queue(maxsize=64)
172
+ self._status_queues.add(queue)
173
+ try:
174
+ if self._latest_status is not None:
175
+ queue.put_nowait(self._latest_status)
176
+ while not self._closed:
177
+ yield await queue.get()
178
+ finally:
179
+ self._status_queues.discard(queue)
180
+
181
+ async def snapshot(self, *, timeout: float = camera_module.DEFAULT_TIMEOUT) -> bytes:
182
+ """Return a single JPEG frame from the built-in webcam."""
183
+ return await camera_module.snapshot(self.host, timeout=timeout)
184
+
185
+ # --- control actions (gated) ----------------------------------------------
186
+
187
+ async def start_print(
188
+ self,
189
+ filename: str,
190
+ *,
191
+ storage: str = "local",
192
+ auto_leveling: bool = True,
193
+ timelapse: bool = False,
194
+ ) -> sdcp.ParsedMessage:
195
+ """Start a print of an existing file on the printer.
196
+
197
+ ``storage`` is either ``"local"`` (internal storage) or ``"udisk"``
198
+ (USB). The filename is the name used by the printer, not a local path.
199
+ """
200
+ self._require_control("start_print")
201
+ path_prefix = "/usb" if storage == "udisk" else "/local"
202
+ data: dict[str, Any] = {
203
+ "Filename": filename,
204
+ "StartLayer": 0,
205
+ "Calibration_switch": 1 if auto_leveling else 0,
206
+ "PrintPlatformType": 0,
207
+ "Tlp_Switch": 1 if timelapse else 0,
208
+ "slot_map": [],
209
+ "path_prefix": path_prefix,
210
+ }
211
+ mid = await self.wait_for_mainboard()
212
+ return await self._request(sdcp.Cmd.START_PRINT, data, mid)
213
+
214
+ async def pause(self) -> sdcp.ParsedMessage:
215
+ self._require_control("pause")
216
+ mid = await self.wait_for_mainboard()
217
+ return await self._request(sdcp.Cmd.PAUSE_PRINT, None, mid)
218
+
219
+ async def resume(self) -> sdcp.ParsedMessage:
220
+ self._require_control("resume")
221
+ mid = await self.wait_for_mainboard()
222
+ return await self._request(sdcp.Cmd.RESUME_PRINT, None, mid)
223
+
224
+ async def stop(self) -> sdcp.ParsedMessage:
225
+ self._require_control("stop")
226
+ mid = await self.wait_for_mainboard()
227
+ return await self._request(sdcp.Cmd.STOP_PRINT, None, mid)
228
+
229
+ # --- lifecycle -------------------------------------------------------------
230
+
231
+ async def close(self) -> None:
232
+ """Close the WebSocket and stop the reader."""
233
+ if self._closed:
234
+ return
235
+ self._closed = True
236
+ if self._reader is not None and not self._reader.done():
237
+ self._reader.cancel()
238
+ with contextlib.suppress(asyncio.CancelledError, Exception):
239
+ await self._reader
240
+ if self._ws is not None:
241
+ with contextlib.suppress(Exception):
242
+ await self._ws.close()
243
+ for fut in self._pending.values():
244
+ if not fut.done():
245
+ fut.set_exception(PrinterError("connection closed"))
246
+
247
+ # --- internals -------------------------------------------------------------
248
+
249
+ def _require_control(self, action: str) -> None:
250
+ if not self.enable_control:
251
+ raise ControlDisabledError(
252
+ f"{action!r} requires enable_control=True; "
253
+ "Printer.connect(..., enable_control=True) to allow write actions"
254
+ )
255
+
256
+ async def _send_raw(self, text: str) -> None:
257
+ if self._ws is None:
258
+ raise PrinterError("not connected")
259
+ await self._ws.send(text)
260
+
261
+ async def _ensure_subscribed(self) -> None:
262
+ mid = await self.wait_for_mainboard()
263
+ pkt = sdcp.build_subscribe(mid, period_ms=self.push_period_ms)
264
+ await self._send_raw(sdcp.encode(pkt))
265
+
266
+ async def _request(
267
+ self,
268
+ cmd: int,
269
+ data: dict[str, Any] | None,
270
+ mainboard_id: str,
271
+ *,
272
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
273
+ ) -> sdcp.ParsedMessage:
274
+ pkt = sdcp.build_request(cmd, data, mainboard_id)
275
+ request_id = pkt["Data"]["RequestID"]
276
+ loop = asyncio.get_running_loop()
277
+ fut: asyncio.Future[sdcp.ParsedMessage] = loop.create_future()
278
+ self._pending[request_id] = fut
279
+ try:
280
+ await self._send_raw(sdcp.encode(pkt))
281
+ return await asyncio.wait_for(fut, timeout=timeout)
282
+ except asyncio.TimeoutError as err:
283
+ raise RequestTimeoutError(f"cmd {cmd} timed out after {timeout}s") from err
284
+ finally:
285
+ self._pending.pop(request_id, None)
286
+
287
+ async def _read_loop(self) -> None:
288
+ assert self._ws is not None
289
+ try:
290
+ async for raw in self._ws:
291
+ try:
292
+ self._handle_frame(raw)
293
+ except Exception:
294
+ log.exception("failed to handle frame: %r", raw[:200] if raw else raw)
295
+ except websockets.ConnectionClosed:
296
+ log.debug("ws closed by peer")
297
+ except Exception:
298
+ log.exception("ws reader crashed")
299
+ finally:
300
+ self._closed = True
301
+ for fut in self._pending.values():
302
+ if not fut.done():
303
+ fut.set_exception(PrinterError("connection closed"))
304
+
305
+ def _handle_frame(self, raw: str | bytes) -> None:
306
+ msg = sdcp.parse_message(raw)
307
+
308
+ if msg.mainboard_id and self._mainboard_id is None:
309
+ self._mainboard_id = msg.mainboard_id
310
+ self._mainboard_event.set()
311
+ log.debug("learned mainboard id: %s", msg.mainboard_id)
312
+
313
+ if msg.type == sdcp.MessageType.STATUS and msg.status is not None:
314
+ status = Status.from_payload(msg.status)
315
+ self._latest_status = status
316
+ self._latest_status_event.set()
317
+ for q in list(self._status_queues):
318
+ with contextlib.suppress(asyncio.QueueFull):
319
+ q.put_nowait(status)
320
+
321
+ elif msg.type == sdcp.MessageType.ATTRIBUTES and msg.attributes is not None:
322
+ self._latest_attributes = Attributes.from_payload(msg.attributes)
323
+ self._latest_attributes_event.set()
324
+
325
+ if msg.request_id and msg.request_id in self._pending:
326
+ fut = self._pending[msg.request_id]
327
+ if not fut.done():
328
+ fut.set_result(msg)
@@ -0,0 +1,96 @@
1
+ """LAN discovery for Elegoo Centauri Carbon printers.
2
+
3
+ The original Centauri Carbon listens on UDP port 3000 and responds to the
4
+ magic probe string ``M99999`` with a JSON payload describing itself. The
5
+ newer Centauri Carbon 2 uses a different JSON-RPC probe and is not supported
6
+ here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import socket
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ DISCOVERY_PORT = 3000
18
+ DISCOVERY_PROBE = b"M99999"
19
+ DEFAULT_TIMEOUT = 3.0
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class DiscoveredPrinter:
24
+ """A printer that answered a discovery broadcast."""
25
+
26
+ host: str
27
+ mainboard_id: str | None
28
+ name: str | None
29
+ machine_name: str | None
30
+ firmware_version: str | None
31
+ raw: dict[str, Any]
32
+
33
+
34
+ def _parse_response(data: bytes, host: str) -> DiscoveredPrinter | None:
35
+ try:
36
+ obj = json.loads(data.decode("utf-8", "replace"))
37
+ except (json.JSONDecodeError, UnicodeDecodeError):
38
+ return None
39
+ if not isinstance(obj, dict):
40
+ return None
41
+ inner_raw = obj.get("Data")
42
+ inner: dict[str, Any] = inner_raw if isinstance(inner_raw, dict) else {}
43
+ return DiscoveredPrinter(
44
+ host=host,
45
+ mainboard_id=inner.get("MainboardID") or obj.get("MainboardID"),
46
+ name=inner.get("Name"),
47
+ machine_name=inner.get("MachineName"),
48
+ firmware_version=inner.get("FirmwareVersion"),
49
+ raw=obj,
50
+ )
51
+
52
+
53
+ class _DiscoveryProtocol(asyncio.DatagramProtocol):
54
+ def __init__(self) -> None:
55
+ self.results: dict[str, DiscoveredPrinter] = {}
56
+
57
+ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
58
+ host = addr[0]
59
+ if host in self.results:
60
+ return
61
+ parsed = _parse_response(data, host)
62
+ if parsed is not None:
63
+ self.results[host] = parsed
64
+
65
+
66
+ async def discover(
67
+ *,
68
+ timeout: float = DEFAULT_TIMEOUT,
69
+ broadcast_address: str = "255.255.255.255",
70
+ port: int = DISCOVERY_PORT,
71
+ ) -> list[DiscoveredPrinter]:
72
+ """Broadcast the SDCP discovery probe and collect responders.
73
+
74
+ Blocks for ``timeout`` seconds. Returns one entry per responding printer,
75
+ de-duplicated by source IP. Safe to call concurrently from multiple
76
+ tasks; each call uses its own UDP socket.
77
+ """
78
+ loop = asyncio.get_running_loop()
79
+
80
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
81
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
82
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
83
+ sock.bind(("", 0))
84
+ sock.setblocking(False)
85
+
86
+ transport, protocol = await loop.create_datagram_endpoint(
87
+ _DiscoveryProtocol,
88
+ sock=sock,
89
+ )
90
+ try:
91
+ transport.sendto(DISCOVERY_PROBE, (broadcast_address, port))
92
+ await asyncio.sleep(timeout)
93
+ finally:
94
+ transport.close()
95
+
96
+ return list(protocol.results.values())
File without changes
@@ -0,0 +1,4 @@
1
+ from pycentauri.mcp.server import _cli
2
+
3
+ if __name__ == "__main__":
4
+ _cli()
@@ -0,0 +1,211 @@
1
+ """FastMCP server exposing the printer to MCP-speaking agents.
2
+
3
+ Register with an agent as a stdio server. Examples:
4
+
5
+ .. code-block:: sh
6
+
7
+ # Read-only tools only:
8
+ claude mcp add pycentauri -- python -m pycentauri.mcp
9
+
10
+ # With control actions (start/pause/resume/stop):
11
+ claude mcp add pycentauri -- python -m pycentauri.mcp --enable-control
12
+
13
+ The printer host is read from ``PYCENTAURI_HOST`` (preferred) or from a
14
+ ``--host`` argument at launch time — it is **not** a per-tool parameter, so
15
+ an LLM cannot be tricked into targeting an arbitrary IP through prompt
16
+ injection.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import os
23
+ from typing import Any
24
+
25
+ from mcp.server.fastmcp import FastMCP, Image
26
+
27
+ from pycentauri.client import Printer
28
+ from pycentauri.discovery import discover as _lan_discover
29
+
30
+ _HOST_ENV = "PYCENTAURI_HOST"
31
+
32
+
33
+ def _resolve_host() -> str:
34
+ host = os.environ.get(_HOST_ENV)
35
+ if not host:
36
+ raise RuntimeError(
37
+ f"{_HOST_ENV} is not set; launch the server with --host IP or "
38
+ "export PYCENTAURI_HOST first"
39
+ )
40
+ return host
41
+
42
+
43
+ def build_server(*, enable_control: bool = False) -> FastMCP:
44
+ """Construct the FastMCP server, registering tools per the control flag.
45
+
46
+ Control tools are not registered at all when ``enable_control=False`` —
47
+ they never appear in the tool list the LLM sees.
48
+ """
49
+ mcp = FastMCP("pycentauri")
50
+
51
+ @mcp.tool()
52
+ async def get_status() -> dict[str, Any]:
53
+ """Return the current printer status.
54
+
55
+ Includes state code, job filename, progress %, layer, temperatures
56
+ (nozzle / bed / chamber), fan speeds, and the raw SDCP payload.
57
+ """
58
+ host = _resolve_host()
59
+ async with await Printer.connect(host) as printer:
60
+ st = await printer.status()
61
+ return {
62
+ "host": host,
63
+ "state": st.state,
64
+ "print_status": st.print_status,
65
+ "progress": st.progress,
66
+ "filename": st.filename,
67
+ "layer": {
68
+ "current": st.print_info.current_layer if st.print_info else None,
69
+ "total": st.print_info.total_layer if st.print_info else None,
70
+ },
71
+ "temperatures": {
72
+ "nozzle": {"actual": st.temp_nozzle, "target": st.temp_nozzle_target},
73
+ "bed": {"actual": st.temp_bed, "target": st.temp_bed_target},
74
+ "chamber": {"actual": st.temp_chamber, "target": st.temp_chamber_target},
75
+ },
76
+ "position": st.coord,
77
+ "z_offset": st.z_offset,
78
+ "fans": st.fan_speed,
79
+ "raw": st.raw,
80
+ }
81
+
82
+ @mcp.tool()
83
+ async def get_attributes() -> dict[str, Any]:
84
+ """Return printer attributes: model, firmware, mainboard ID, capabilities."""
85
+ host = _resolve_host()
86
+ async with await Printer.connect(host) as printer:
87
+ attrs = await printer.attributes()
88
+ return {
89
+ "host": host,
90
+ "mainboard_id": attrs.mainboard_id,
91
+ "name": attrs.name,
92
+ "machine_name": attrs.machine_name,
93
+ "firmware_version": attrs.firmware_version,
94
+ "capabilities": attrs.capabilities,
95
+ "raw": attrs.raw,
96
+ }
97
+
98
+ @mcp.tool()
99
+ async def get_snapshot() -> Image:
100
+ """Return a JPEG snapshot of the built-in webcam.
101
+
102
+ The image is returned inline so the agent can see what the printer
103
+ is currently doing (e.g. to spot layer shifts or spaghetti).
104
+ """
105
+ host = _resolve_host()
106
+ async with await Printer.connect(host) as printer:
107
+ jpeg = await printer.snapshot()
108
+ return Image(data=jpeg, format="jpeg")
109
+
110
+ @mcp.tool()
111
+ async def discover_printers() -> list[dict[str, Any]]:
112
+ """Broadcast the SDCP discovery probe and return responding printers.
113
+
114
+ Useful to verify the configured host matches what's actually on the
115
+ LAN, or to find a newly-added printer's IP.
116
+ """
117
+ found = await _lan_discover(timeout=2.5)
118
+ return [
119
+ {
120
+ "host": p.host,
121
+ "mainboard_id": p.mainboard_id,
122
+ "name": p.name,
123
+ "machine_name": p.machine_name,
124
+ "firmware_version": p.firmware_version,
125
+ }
126
+ for p in found
127
+ ]
128
+
129
+ if not enable_control:
130
+ return mcp
131
+
132
+ # --- Control tools (registered only when explicitly enabled) --------------
133
+
134
+ @mcp.tool()
135
+ async def start_print(
136
+ filename: str,
137
+ storage: str = "local",
138
+ auto_leveling: bool = True,
139
+ timelapse: bool = False,
140
+ ) -> dict[str, Any]:
141
+ """DESTRUCTIVE. Start a print of ``filename`` (already on the printer).
142
+
143
+ Requires a file that has been uploaded to the printer. ``storage`` is
144
+ ``"local"`` (default) or ``"udisk"``. Ask the user for confirmation
145
+ before invoking — running a print unattended is the user's risk.
146
+ """
147
+ host = _resolve_host()
148
+ async with await Printer.connect(host, enable_control=True) as printer:
149
+ result = await printer.start_print(
150
+ filename,
151
+ storage=storage,
152
+ auto_leveling=auto_leveling,
153
+ timelapse=timelapse,
154
+ )
155
+ return {"ok": True, "response": result.inner}
156
+
157
+ @mcp.tool()
158
+ async def pause_print() -> dict[str, Any]:
159
+ """DESTRUCTIVE. Pause the current print. Ask the user before invoking."""
160
+ host = _resolve_host()
161
+ async with await Printer.connect(host, enable_control=True) as printer:
162
+ result = await printer.pause()
163
+ return {"ok": True, "response": result.inner}
164
+
165
+ @mcp.tool()
166
+ async def resume_print() -> dict[str, Any]:
167
+ """Resume a paused print."""
168
+ host = _resolve_host()
169
+ async with await Printer.connect(host, enable_control=True) as printer:
170
+ result = await printer.resume()
171
+ return {"ok": True, "response": result.inner}
172
+
173
+ @mcp.tool()
174
+ async def stop_print() -> dict[str, Any]:
175
+ """DESTRUCTIVE. Stop the current print. Ask the user before invoking."""
176
+ host = _resolve_host()
177
+ async with await Printer.connect(host, enable_control=True) as printer:
178
+ result = await printer.stop()
179
+ return {"ok": True, "response": result.inner}
180
+
181
+ return mcp
182
+
183
+
184
+ def run_stdio(*, enable_control: bool = False) -> None:
185
+ """Run the MCP server over stdio until the transport closes."""
186
+ mcp = build_server(enable_control=enable_control)
187
+ mcp.run()
188
+
189
+
190
+ def _cli() -> None:
191
+ parser = argparse.ArgumentParser(
192
+ prog="python -m pycentauri.mcp",
193
+ description="MCP server for pycentauri (stdio transport)",
194
+ )
195
+ parser.add_argument(
196
+ "--enable-control",
197
+ action="store_true",
198
+ help="Register destructive tools (start/pause/resume/stop).",
199
+ )
200
+ parser.add_argument(
201
+ "--host",
202
+ help="Printer host/IP. Overrides $PYCENTAURI_HOST for this process.",
203
+ )
204
+ args = parser.parse_args()
205
+ if args.host:
206
+ os.environ[_HOST_ENV] = args.host
207
+ run_stdio(enable_control=args.enable_control)
208
+
209
+
210
+ if __name__ == "__main__":
211
+ _cli()