stackchan-mcp 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (33) hide show
  1. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/PKG-INFO +1 -1
  2. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/pyproject.toml +1 -1
  3. stackchan_mcp-0.3.0/stackchan_mcp/__init__.py +12 -0
  4. stackchan_mcp-0.3.0/stackchan_mcp/cli.py +540 -0
  5. stackchan_mcp-0.3.0/tests/test_cli.py +735 -0
  6. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/uv.lock +1 -1
  7. stackchan_mcp-0.2.0/stackchan_mcp/__init__.py +0 -7
  8. stackchan_mcp-0.2.0/stackchan_mcp/cli.py +0 -57
  9. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/.env.example +0 -0
  10. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/.gitignore +0 -0
  11. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/LICENSE +0 -0
  12. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/README.md +0 -0
  13. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/__main__.py +0 -0
  14. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/audio_stream.py +0 -0
  15. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/capture_server.py +0 -0
  16. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/esp32_client.py +0 -0
  17. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/gateway.py +0 -0
  18. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/handlers/__init__.py +0 -0
  19. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/handlers/audio.py +0 -0
  20. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/handlers/camera.py +0 -0
  21. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/handlers/robot.py +0 -0
  22. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/mcp_router.py +0 -0
  23. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/protocol.py +0 -0
  24. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/server.py +0 -0
  25. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/stdio_server.py +0 -0
  26. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/tools.py +0 -0
  27. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/conftest.py +0 -0
  28. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_capture_server.py +0 -0
  29. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_esp32_client.py +0 -0
  30. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_gateway.py +0 -0
  31. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_mcp_router.py +0 -0
  32. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_protocol.py +0 -0
  33. {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_stdio_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackchan-mcp
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP.
5
5
  Project-URL: Homepage, https://github.com/kisaragi-mochi/stackchan-mcp
6
6
  Project-URL: Repository, https://github.com/kisaragi-mochi/stackchan-mcp
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackchan-mcp"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,12 @@
1
+ """stackchan-mcp: Two-faced gateway for StackChan (xiaozhi-esp32).
2
+
3
+ MCP client side: stdio MCP server (mcp Python SDK)
4
+ ESP32 side: WebSocket server (MCP client over JSON-RPC 2.0)
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ __version__ = version("stackchan-mcp")
11
+ except PackageNotFoundError: # pragma: no cover - source checkout without install
12
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,540 @@
1
+ """Console entry point for stackchan-mcp.
2
+
3
+ This module exists so that `import stackchan_mcp` (or any of its
4
+ submodules) does not trigger import-time side effects like
5
+ `load_dotenv()` or logging configuration. All such side effects live
6
+ inside :func:`main`, which is registered as the `stackchan-mcp`
7
+ console script in ``pyproject.toml`` and is also re-exported through
8
+ ``stackchan_mcp.__main__`` so that ``python -m stackchan_mcp`` keeps
9
+ working.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import errno
17
+ import logging
18
+ import os
19
+ import shutil
20
+ import socket
21
+ import subprocess
22
+ import sys
23
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
24
+
25
+ from . import __version__
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ _DESCRIPTION = (
31
+ "stdio MCP gateway for the StackChan / xiaozhi-esp32 firmware. "
32
+ "Bridges stdio MCP clients (for example Claude Code) to a StackChan "
33
+ "ESP32 device over WebSocket, and exposes an HTTP capture endpoint "
34
+ "for photo uploads from the device."
35
+ )
36
+
37
+ _EPILOG = """\
38
+ Environment variables:
39
+ STACKCHAN_TOKEN Bearer token shared with the ESP32 firmware.
40
+ VISION_URL Full public capture URL (e.g. Tailscale Funnel).
41
+ VISION_HOST LAN IP of this machine, as seen from the ESP32.
42
+ VISION_TOKEN Optional separate token for VISION_URL uploads.
43
+ HOST Bind address for the ESP32 WebSocket server (default 0.0.0.0).
44
+ WS_PORT Port for the ESP32 WebSocket server (default 8765).
45
+ CAPTURE_PORT Port for the HTTP capture server (default 8766).
46
+
47
+ See gateway/README.md and the top-level README.md for full setup,
48
+ including pairing the ESP32 firmware and configuring the WiFi gateway URL.
49
+ """
50
+
51
+
52
+ def _build_arg_parser() -> argparse.ArgumentParser:
53
+ parser = argparse.ArgumentParser(
54
+ prog="stackchan-mcp",
55
+ description=_DESCRIPTION,
56
+ epilog=_EPILOG,
57
+ formatter_class=argparse.RawDescriptionHelpFormatter,
58
+ )
59
+ parser.add_argument(
60
+ "-V",
61
+ "--version",
62
+ action="version",
63
+ version=f"%(prog)s {__version__}",
64
+ )
65
+ parser.add_argument(
66
+ "--check",
67
+ action="store_true",
68
+ help=(
69
+ "Run a non-destructive preflight (configuration, port "
70
+ "availability, derived URLs) and exit. Exit 0 if ready to run, "
71
+ "non-zero if at least one blocking issue is found."
72
+ ),
73
+ )
74
+ return parser
75
+
76
+
77
+ # --- Preflight diagnostics (--check) ----------------------------------------
78
+ #
79
+ # The preflight is intentionally side-effect-free: it loads ``.env``, reads
80
+ # environment variables, attempts non-blocking ``bind()`` calls to the two
81
+ # server ports, and prints a concise human-readable report. It does NOT
82
+ # reach out to any ESP32, does not start either server, and does not modify
83
+ # any files. Live device connectivity belongs in a future ``status``
84
+ # subcommand (Issue #54 "Out of scope" note).
85
+
86
+
87
+ _BIND_ERROR_PREFIX = "bind error: "
88
+
89
+
90
+ def _check_port(host: str, port: int) -> tuple[bool, str | None]:
91
+ """Probe ``(host, port)`` by trying to ``bind()`` it across every family.
92
+
93
+ Resolves ``host`` via ``getaddrinfo`` with ``AF_UNSPEC`` and walks
94
+ each (family, sockaddr) candidate so the preflight matches the
95
+ same dual-stack behaviour as ``websockets.serve`` / ``aiohttp``.
96
+ A literal ``::1`` or an IPv6-resolving ``localhost`` is therefore
97
+ probed against the right address family rather than being
98
+ misreported by an ``AF_INET``-only socket.
99
+
100
+ Returns ``(available, info)``:
101
+
102
+ - ``(True, None)``: at least one address family bound successfully.
103
+ (Some IPv6 stacks fail with ``EADDRNOTAVAIL`` on hosts without a
104
+ configured v6 interface; the gateway also tolerates that, so we
105
+ report "ready" if any candidate succeeded.)
106
+ - ``(False, "pid <N>, <cmd>")``: at least one candidate reported
107
+ ``EADDRINUSE``. We short-circuit on the first one because the
108
+ gateway will collide with the holder regardless of any other
109
+ family that may have been free.
110
+ - ``(False, None)``: same as above, but ``lsof`` could not identify
111
+ the holder (or is unavailable on this platform).
112
+ - ``(False, "bind error: <reason>")``: every candidate failed for
113
+ a non-``EADDRINUSE`` reason (typically ``EADDRNOTAVAIL`` for an
114
+ IP that is not assigned to any local interface, or ``EACCES`` on
115
+ a privileged port without permission). Distinguishing this from
116
+ "in use" prevents users from chasing a phantom process when the
117
+ real issue is the bind address.
118
+ """
119
+ try:
120
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
121
+ except socket.gaierror as exc:
122
+ return (False, f"{_BIND_ERROR_PREFIX}getaddrinfo failed: {exc}")
123
+
124
+ last_error: str | None = None
125
+ bound_at_least_once = False
126
+ for family, socktype, proto, _canonname, sockaddr in infos:
127
+ sock = socket.socket(family, socktype, proto)
128
+ # Mirror ``asyncio.create_server``'s default behaviour on POSIX:
129
+ # the gateway sets SO_REUSEADDR=1, so a port in TIME_WAIT after
130
+ # a recent gateway restart would NOT actually block a fresh
131
+ # bind. Without this option the preflight would misreport such
132
+ # a port as IN USE and exit non-zero, even though the gateway
133
+ # itself would start cleanly. SO_REUSEADDR does not let the
134
+ # bind succeed when another process is currently LISTENing on
135
+ # the port (POSIX semantics), so the EADDRINUSE branch below
136
+ # still fires for genuine collisions.
137
+ if hasattr(socket, "SO_REUSEADDR"):
138
+ try:
139
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
140
+ except OSError:
141
+ # Some platforms reject SO_REUSEADDR for certain socket
142
+ # types; fall through and try the bind anyway.
143
+ pass
144
+ try:
145
+ try:
146
+ sock.bind(sockaddr)
147
+ except OSError as exc:
148
+ if exc.errno == errno.EADDRINUSE:
149
+ # Mirror gateway behaviour: an EADDRINUSE on any
150
+ # candidate family means the gateway will collide.
151
+ return (False, _try_get_port_holder(port))
152
+ reason = exc.strerror or (
153
+ os.strerror(exc.errno)
154
+ if exc.errno is not None
155
+ else str(exc)
156
+ )
157
+ last_error = f"{_BIND_ERROR_PREFIX}{reason}"
158
+ else:
159
+ bound_at_least_once = True
160
+ finally:
161
+ sock.close()
162
+
163
+ if bound_at_least_once:
164
+ return (True, None)
165
+ return (False, last_error)
166
+
167
+
168
+ def _try_get_port_holder(port: int) -> str | None:
169
+ """Best-effort lookup of the process holding ``port`` via ``lsof``.
170
+
171
+ Returns ``"pid <N>, <cmd>"`` on success, or ``None`` if ``lsof`` is
172
+ not installed, the call fails, or the port is not in fact held (for
173
+ example, the bind failure was due to a permission error rather than
174
+ EADDRINUSE).
175
+ """
176
+ if shutil.which("lsof") is None:
177
+ return None
178
+ try:
179
+ result = subprocess.run(
180
+ ["lsof", f"-iTCP:{port}", "-sTCP:LISTEN", "-Fpcn"],
181
+ capture_output=True,
182
+ text=True,
183
+ timeout=2,
184
+ check=False,
185
+ )
186
+ except (OSError, subprocess.SubprocessError):
187
+ return None
188
+ if result.returncode != 0 or not result.stdout:
189
+ return None
190
+ pid: str | None = None
191
+ cmd: str | None = None
192
+ for line in result.stdout.splitlines():
193
+ if line.startswith("p"):
194
+ pid = line[1:]
195
+ elif line.startswith("c"):
196
+ cmd = line[1:]
197
+ if pid and cmd:
198
+ return f"pid {pid}, {cmd}"
199
+ if pid:
200
+ return f"pid {pid}"
201
+ return None
202
+
203
+
204
+ def _format_port_status(available: bool, holder: str | None) -> str:
205
+ if available:
206
+ return "AVAILABLE"
207
+ if holder is None:
208
+ return "IN USE"
209
+ if holder.startswith(_BIND_ERROR_PREFIX):
210
+ # Don't say "IN USE" for non-EADDRINUSE bind failures
211
+ # (EADDRNOTAVAIL, EACCES, etc.). Surface the actual reason
212
+ # instead so the user does not chase a phantom process.
213
+ reason = holder.removeprefix(_BIND_ERROR_PREFIX)
214
+ return f"BIND ERROR ({reason})"
215
+ return f"IN USE ({holder})"
216
+
217
+
218
+ _TCP_PORT_RANGE = range(0, 65536)
219
+
220
+
221
+ def _validate_port_value(raw: str, var: str) -> tuple[int | None, str]:
222
+ """Parse ``raw`` as a TCP port, returning ``(port, source_or_error)``.
223
+
224
+ Returns ``(int_value, var)`` for a valid in-range integer (0..65535
225
+ inclusive — ``0`` lets the OS pick, which the gateway may not
226
+ actually want but is at least bind-able). Returns ``(None, "<var>=
227
+ <raw> (...)")`` otherwise; the caller treats that as a blocking
228
+ issue rather than silently falling through to a default.
229
+
230
+ Both branches matter for the preflight: ``socket.bind()`` raises
231
+ ``OverflowError`` for values outside the TCP port range, so without
232
+ this validation ``--check`` would crash with a stack trace instead
233
+ of producing the diagnostic report it exists to produce.
234
+ """
235
+ try:
236
+ value = int(raw)
237
+ except ValueError:
238
+ return (None, f"{var}={raw!r} (not an integer)")
239
+ if value not in _TCP_PORT_RANGE:
240
+ return (None, f"{var}={raw!r} (out of TCP port range 0-65535)")
241
+ return (value, var)
242
+
243
+
244
+ def _resolve_ws_port() -> tuple[int | None, str]:
245
+ """Resolve the WebSocket port using the same precedence as ``gateway.py``.
246
+
247
+ Mirrors ``int(os.getenv("WS_PORT", os.getenv("PORT", "8765")))`` from
248
+ ``gateway.py`` so the preflight checks the port the gateway will
249
+ actually bind, not a hard-coded default. See ``_validate_port_value``
250
+ for the validation rules; on success returns ``(port, "WS_PORT")``
251
+ or ``(port, "PORT")``, otherwise ``(None, "<var>=<raw> (...)")``.
252
+ """
253
+ for var in ("WS_PORT", "PORT"):
254
+ raw = os.getenv(var)
255
+ if raw is None:
256
+ continue
257
+ return _validate_port_value(raw, var)
258
+ return (8765, "default")
259
+
260
+
261
+ def _resolve_capture_port() -> tuple[int | None, str]:
262
+ """Resolve the HTTP capture port using ``gateway.py``'s precedence.
263
+
264
+ Mirrors ``int(os.getenv("CAPTURE_PORT", "8766"))``. See
265
+ ``_validate_port_value`` for the validation rules.
266
+ """
267
+ raw = os.getenv("CAPTURE_PORT")
268
+ if raw is None:
269
+ return (8766, "default")
270
+ return _validate_port_value(raw, "CAPTURE_PORT")
271
+
272
+
273
+ # Exact query parameter names that are always redacted in preflight
274
+ # output. Compared lowercased.
275
+ _SECRET_QUERY_KEYS = frozenset(
276
+ {
277
+ "access_token",
278
+ "api_key",
279
+ "apikey",
280
+ "auth",
281
+ "auth_token",
282
+ "key",
283
+ "password",
284
+ "secret",
285
+ "sig",
286
+ "signature",
287
+ "token",
288
+ }
289
+ )
290
+
291
+ # Suffix-based heuristic for redacting provider-specific signed-URL
292
+ # parameters without enumerating every variant. Matches things like
293
+ # ``X-Amz-Signature``, ``X-Amz-Security-Token``, ``X-Goog-Signature``,
294
+ # Azure SAS ``sig`` (already covered by the exact set), generic
295
+ # ``*_token`` / ``*-secret`` patterns, etc. Compared lowercased.
296
+ _SECRET_QUERY_KEY_SUFFIXES = (
297
+ "signature",
298
+ "token",
299
+ "secret",
300
+ "password",
301
+ "credential",
302
+ "credentials",
303
+ )
304
+
305
+
306
+ def _is_secret_query_key(key: str) -> bool:
307
+ lower = key.lower()
308
+ if lower in _SECRET_QUERY_KEYS:
309
+ return True
310
+ return any(lower.endswith(suffix) for suffix in _SECRET_QUERY_KEY_SUFFIXES)
311
+
312
+
313
+ def _redact_url_secrets(url: str) -> str:
314
+ """Mask userinfo and secret-looking query params in ``url``.
315
+
316
+ The preflight output is meant to be safe to paste into a public
317
+ issue or log, so any in-URL credential is replaced before
318
+ printing:
319
+
320
+ - ``https://user:pass@host/path`` → ``https://***:***@host/path``
321
+ - ``?token=abc&page=1`` → ``?token=%2A%2A%2Aredacted%2A%2A%2A&page=1``
322
+ (only keys in ``_SECRET_QUERY_KEYS`` are touched; non-secret
323
+ params keep their value)
324
+
325
+ Non-credential structure (scheme, host, port, path, fragment,
326
+ non-secret query keys) is preserved so users can still see what
327
+ the gateway is actually configured to call. Inputs that fail to
328
+ parse are returned unchanged so the preflight never crashes on a
329
+ malformed value.
330
+ """
331
+ try:
332
+ parsed = urlparse(url)
333
+ except (ValueError, TypeError):
334
+ return url
335
+
336
+ netloc = parsed.netloc
337
+ if "@" in netloc:
338
+ # Strip userinfo and replace with a fixed placeholder. Don't
339
+ # try to preserve the username — the username alone can leak
340
+ # information and the structural info we care about (host,
341
+ # port) lives after the @.
342
+ _userinfo, _, host_part = netloc.rpartition("@")
343
+ netloc = f"***:***@{host_part}"
344
+
345
+ query = parsed.query
346
+ if query:
347
+ try:
348
+ params = parse_qsl(query, keep_blank_values=True)
349
+ except ValueError:
350
+ params = None
351
+ if params is not None:
352
+ redacted = [
353
+ (k, "***redacted***") if _is_secret_query_key(k) else (k, v)
354
+ for k, v in params
355
+ ]
356
+ query = urlencode(redacted)
357
+
358
+ return urlunparse(
359
+ (parsed.scheme, netloc, parsed.path, parsed.params, query, parsed.fragment)
360
+ )
361
+
362
+
363
+ def _load_dotenv() -> None:
364
+ """Lazy ``.env`` loader exposed as a single attachable seam.
365
+
366
+ Wrapping ``python-dotenv`` here keeps two properties:
367
+
368
+ 1. ``import stackchan_mcp.cli`` stays side-effect free (the
369
+ ``dotenv`` import only happens when the gateway / preflight is
370
+ actually invoked).
371
+ 2. Tests can ``monkeypatch.setattr(cli, "_load_dotenv", ...)`` to
372
+ prevent the real ``find_dotenv()`` walking up to the developer's
373
+ ``gateway/.env`` and contaminating environment-isolation tests.
374
+ """
375
+ from dotenv import load_dotenv
376
+
377
+ load_dotenv()
378
+
379
+
380
+ def _run_preflight() -> int:
381
+ """Run preflight diagnostics. Returns the desired process exit code.
382
+
383
+ Output is intentionally fixed-width and grep-friendly. Exit 0 means
384
+ "ready to run"; non-zero means at least one blocking issue (currently
385
+ only port unavailability). Missing optional configuration is reported
386
+ but does not fail the check, mirroring how the gateway itself only
387
+ warns about a missing ``STACKCHAN_TOKEN``.
388
+ """
389
+ _load_dotenv()
390
+
391
+ issues = 0
392
+ print(f"stackchan-mcp {__version__} preflight")
393
+ print()
394
+
395
+ # --- Configuration ------------------------------------------------------
396
+ print("Configuration:")
397
+ token = os.getenv("STACKCHAN_TOKEN") or os.getenv("BEARER_TOKEN")
398
+ if token:
399
+ print(" STACKCHAN_TOKEN set (***redacted***)")
400
+ else:
401
+ print(" STACKCHAN_TOKEN not set (gateway will accept any client)")
402
+
403
+ vision_host = os.getenv("VISION_HOST", "")
404
+ capture_port_raw = os.getenv("CAPTURE_PORT", "8766")
405
+ if vision_host:
406
+ print(f" VISION_HOST {vision_host}")
407
+ else:
408
+ print(" VISION_HOST not set")
409
+
410
+ vision_url_explicit = os.getenv("VISION_URL", "")
411
+ if vision_url_explicit:
412
+ print(f" VISION_URL {_redact_url_secrets(vision_url_explicit)}")
413
+ elif vision_host:
414
+ # Derived URL has no userinfo or query params, so no redaction
415
+ # needed; the host part is shown as-is by design (it is the IP
416
+ # the user has configured for capture).
417
+ derived = f"http://{vision_host}:{capture_port_raw}/capture"
418
+ print(f" VISION_URL (derived) {derived}")
419
+ else:
420
+ print(
421
+ " VISION_URL not set "
422
+ "(set VISION_HOST or VISION_URL for take_photo)"
423
+ )
424
+
425
+ if os.getenv("VISION_TOKEN"):
426
+ print(" VISION_TOKEN set (***redacted***)")
427
+ else:
428
+ print(" VISION_TOKEN not set (will reuse STACKCHAN_TOKEN)")
429
+
430
+ # --- Ports --------------------------------------------------------------
431
+ print()
432
+ print("Ports:")
433
+ host = os.getenv("HOST", "0.0.0.0")
434
+ ws_port, ws_source = _resolve_ws_port()
435
+ cap_port, cap_source = _resolve_capture_port()
436
+
437
+ if ws_port is None:
438
+ print(f" ws://{host}:??? INVALID ({ws_source})")
439
+ issues += 1
440
+ if cap_port is None:
441
+ print(f" http://{host}:??? INVALID ({cap_source})")
442
+ issues += 1
443
+
444
+ if (
445
+ ws_port is not None
446
+ and cap_port is not None
447
+ and ws_port == cap_port
448
+ and ws_port != 0
449
+ ):
450
+ # The gateway runs WebSocket and HTTP capture as separate
451
+ # listeners; binding the WebSocket server first will then make
452
+ # the HTTP bind fail. Independent _check_port probes can't see
453
+ # this on their own (each one binds-and-releases), so we surface
454
+ # the conflict explicitly.
455
+ #
456
+ # Port 0 is excluded: each ``bind((host, 0))`` asks the OS for a
457
+ # fresh ephemeral port, so two listeners both configured with 0
458
+ # do NOT collide (this is exactly the configuration the existing
459
+ # gateway tests use).
460
+ print(
461
+ f" WS_PORT ({ws_source}) and CAPTURE_PORT ({cap_source}) "
462
+ f"both resolve to {ws_port}; the gateway needs distinct ports."
463
+ )
464
+ issues += 1
465
+
466
+ if ws_port is not None:
467
+ ws_available, ws_holder = _check_port(host, ws_port)
468
+ print(
469
+ f" ws://{host}:{ws_port} "
470
+ f"{_format_port_status(ws_available, ws_holder)}"
471
+ )
472
+ if not ws_available:
473
+ issues += 1
474
+
475
+ if cap_port is not None:
476
+ cap_available, cap_holder = _check_port(host, cap_port)
477
+ print(
478
+ f" http://{host}:{cap_port} "
479
+ f"{_format_port_status(cap_available, cap_holder)}"
480
+ )
481
+ if not cap_available:
482
+ issues += 1
483
+
484
+ # --- Result -------------------------------------------------------------
485
+ print()
486
+ if issues == 0:
487
+ print("Result: ready. Exit 0.")
488
+ return 0
489
+ plural = "s" if issues > 1 else ""
490
+ print(f"Result: {issues} issue{plural}. Exit 1.")
491
+ return 1
492
+
493
+
494
+ async def _run() -> None:
495
+ """Start both the ESP32 WebSocket server and the stdio MCP server."""
496
+ from .gateway import get_gateway
497
+ from .stdio_server import run_stdio_server
498
+
499
+ gateway = get_gateway()
500
+
501
+ await gateway.start()
502
+ logger.info("Gateway started, waiting for ESP32 connections...")
503
+
504
+ try:
505
+ # Run stdio MCP server (blocks until MCP client disconnects)
506
+ await run_stdio_server()
507
+ finally:
508
+ await gateway.stop()
509
+
510
+
511
+ def main(argv: list[str] | None = None) -> None:
512
+ """Console-script entry point.
513
+
514
+ Parses ``--help`` / ``--version`` / ``--check`` early (without
515
+ starting the server), then loads ``.env``, configures logging, and
516
+ starts the gateway. Side effects are intentionally scoped to this
517
+ function so that ``import stackchan_mcp`` stays clean.
518
+ """
519
+ parser = _build_arg_parser()
520
+ # argparse exits with status 0 on --help / --version before reaching
521
+ # any of the gateway start-up below, which is the intended behaviour.
522
+ args = parser.parse_args(argv)
523
+
524
+ if args.check:
525
+ # ``_run_preflight`` loads ``.env`` itself; do not double-load
526
+ # via the path below.
527
+ sys.exit(_run_preflight())
528
+
529
+ _load_dotenv()
530
+
531
+ logging.basicConfig(
532
+ level=logging.INFO,
533
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
534
+ )
535
+
536
+ asyncio.run(_run())
537
+
538
+
539
+ if __name__ == "__main__":
540
+ main()