stackchan-mcp 0.9.1__py3-none-win_amd64.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.
Files changed (43) hide show
  1. stackchan_mcp/__init__.py +81 -0
  2. stackchan_mcp/__main__.py +12 -0
  3. stackchan_mcp/_libs/SOURCES.md +130 -0
  4. stackchan_mcp/_libs/opus.dll +0 -0
  5. stackchan_mcp/audio_input_hook.py +432 -0
  6. stackchan_mcp/audio_stream.py +162 -0
  7. stackchan_mcp/capture_server.py +469 -0
  8. stackchan_mcp/cli.py +958 -0
  9. stackchan_mcp/esp32_client.py +983 -0
  10. stackchan_mcp/event_log.py +189 -0
  11. stackchan_mcp/gateway.py +274 -0
  12. stackchan_mcp/handlers/__init__.py +7 -0
  13. stackchan_mcp/handlers/audio.py +21 -0
  14. stackchan_mcp/handlers/camera.py +25 -0
  15. stackchan_mcp/handlers/robot.py +52 -0
  16. stackchan_mcp/http_server.py +398 -0
  17. stackchan_mcp/mcp_router.py +126 -0
  18. stackchan_mcp/mdns_advertiser.py +347 -0
  19. stackchan_mcp/notify.example.yml +21 -0
  20. stackchan_mcp/notify_config.py +235 -0
  21. stackchan_mcp/ownership.py +270 -0
  22. stackchan_mcp/protocol.py +95 -0
  23. stackchan_mcp/queue.py +191 -0
  24. stackchan_mcp/server.py +28 -0
  25. stackchan_mcp/stdio_server.py +1365 -0
  26. stackchan_mcp/stt/__init__.py +62 -0
  27. stackchan_mcp/stt/audio_utils.py +102 -0
  28. stackchan_mcp/stt/base.py +94 -0
  29. stackchan_mcp/stt/faster_whisper.py +217 -0
  30. stackchan_mcp/stt/openai_whisper.py +177 -0
  31. stackchan_mcp/stt/orchestrator.py +568 -0
  32. stackchan_mcp/tools.py +82 -0
  33. stackchan_mcp/tts/__init__.py +62 -0
  34. stackchan_mcp/tts/audio_utils.py +177 -0
  35. stackchan_mcp/tts/base.py +86 -0
  36. stackchan_mcp/tts/orchestrator.py +688 -0
  37. stackchan_mcp/tts/voicevox.py +184 -0
  38. stackchan_mcp-0.9.1.dist-info/METADATA +324 -0
  39. stackchan_mcp-0.9.1.dist-info/RECORD +43 -0
  40. stackchan_mcp-0.9.1.dist-info/WHEEL +5 -0
  41. stackchan_mcp-0.9.1.dist-info/entry_points.txt +2 -0
  42. stackchan_mcp-0.9.1.dist-info/licenses/LICENSE +39 -0
  43. stackchan_mcp-0.9.1.dist-info/licenses/LICENSE-THIRD-PARTY +65 -0
stackchan_mcp/cli.py ADDED
@@ -0,0 +1,958 @@
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 atexit
15
+ import argparse
16
+ import asyncio
17
+ import errno
18
+ import logging
19
+ import os
20
+ import platform
21
+ import shutil
22
+ import socket
23
+ import subprocess
24
+ import sys
25
+ from typing import TYPE_CHECKING
26
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
27
+
28
+ from . import __version__
29
+
30
+ if TYPE_CHECKING:
31
+ from .ownership import LockInfo, LockMode
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ _DESCRIPTION = (
37
+ "stdio MCP gateway for the StackChan / xiaozhi-esp32 firmware. "
38
+ "Bridges stdio MCP clients (for example Claude Code) to a StackChan "
39
+ "ESP32 device over WebSocket, and exposes an HTTP capture endpoint "
40
+ "for photo uploads from the device."
41
+ )
42
+
43
+ _EPILOG = """\
44
+ Environment variables:
45
+ STACKCHAN_TOKEN Bearer token shared with the ESP32 firmware.
46
+ VISION_URL Full public capture URL (e.g. Tailscale Funnel).
47
+ VISION_HOST LAN IP of this machine, as seen from the ESP32.
48
+ VISION_TOKEN Optional separate token for VISION_URL uploads.
49
+ STACKCHAN_AUDIO_HOOK_URL Enables device-driven listen capture push.
50
+ When set, Opus audio from a wake-word /
51
+ button / LCD-touch initiated listen window
52
+ is packed into Ogg/Opus and POSTed here.
53
+ Leave unset to keep the gateway's behaviour
54
+ unchanged from MCP-driven listen() only.
55
+ STACKCHAN_AUDIO_HOOK_TOKEN
56
+ Bearer token for the audio hook endpoint;
57
+ falls back to STACKCHAN_TOKEN.
58
+ HOST Bind address for the ESP32 WebSocket server
59
+ (default 0.0.0.0).
60
+ WS_PORT Port for the ESP32 WebSocket server
61
+ (default 8765).
62
+ CAPTURE_PORT Port for the HTTP capture server
63
+ (default 8766).
64
+ MCP_HTTP_HOST Bind address for the Streamable HTTP MCP server
65
+ (default 127.0.0.1).
66
+ MCP_HTTP_PORT Port for the Streamable HTTP MCP server
67
+ (default 8767).
68
+ MCP_HTTP_ALLOWED_HOSTS Comma-separated Host / Origin allowlist entries
69
+ for non-loopback Streamable HTTP clients.
70
+
71
+ See gateway/README.md and the top-level README.md for full setup,
72
+ including pairing the ESP32 firmware and configuring the WiFi gateway URL.
73
+ """
74
+
75
+ _STDIO_TRANSPORT = "stdio"
76
+ _STREAMABLE_HTTP_TRANSPORT = "streamable-http"
77
+ _TRANSPORT_CHOICES = (_STDIO_TRANSPORT, _STREAMABLE_HTTP_TRANSPORT)
78
+
79
+
80
+ def _build_arg_parser() -> argparse.ArgumentParser:
81
+ parser = argparse.ArgumentParser(
82
+ prog="stackchan-mcp",
83
+ description=_DESCRIPTION,
84
+ epilog=_EPILOG,
85
+ formatter_class=argparse.RawDescriptionHelpFormatter,
86
+ )
87
+ parser.add_argument(
88
+ "-V",
89
+ "--version",
90
+ action="version",
91
+ version=f"%(prog)s {__version__}",
92
+ )
93
+ parser.add_argument(
94
+ "--check",
95
+ action="store_true",
96
+ help="Print the current gateway ownership lock status and exit.",
97
+ )
98
+ parser.add_argument(
99
+ "--preflight",
100
+ action="store_true",
101
+ help=(
102
+ "Run a non-destructive configuration and port preflight, then "
103
+ "exit. Exit 0 if ready to run, non-zero if at least one "
104
+ "blocking issue is found."
105
+ ),
106
+ )
107
+ parser.add_argument(
108
+ "--no-mdns",
109
+ action="store_true",
110
+ help="Disable mDNS/DNS-SD advertisement for the WebSocket endpoint.",
111
+ )
112
+ subparsers = parser.add_subparsers(dest="command", metavar="{serve}")
113
+ serve_parser = subparsers.add_parser(
114
+ "serve",
115
+ help="Start the StackChan gateway.",
116
+ description="Start the StackChan gateway using the selected transport.",
117
+ formatter_class=argparse.RawDescriptionHelpFormatter,
118
+ )
119
+ serve_parser.add_argument(
120
+ "--transport",
121
+ choices=_TRANSPORT_CHOICES,
122
+ default=_STDIO_TRANSPORT,
123
+ help="Gateway transport to serve (default: stdio).",
124
+ )
125
+ serve_parser.add_argument(
126
+ "--no-mdns",
127
+ dest="serve_no_mdns",
128
+ action="store_true",
129
+ help="Disable mDNS/DNS-SD advertisement for the WebSocket endpoint.",
130
+ )
131
+ return parser
132
+
133
+
134
+ # --- Preflight diagnostics (--check) ----------------------------------------
135
+ #
136
+ # The preflight is intentionally side-effect-free: it loads ``.env``, reads
137
+ # environment variables, attempts non-blocking ``bind()`` calls to the two
138
+ # server ports, and prints a concise human-readable report. It does NOT
139
+ # reach out to any ESP32, does not start either server, and does not modify
140
+ # any files. Live device connectivity belongs in a future ``status``
141
+ # subcommand (Issue #54 "Out of scope" note).
142
+
143
+
144
+ _BIND_ERROR_PREFIX = "bind error: "
145
+
146
+
147
+ def _check_port(host: str, port: int) -> tuple[bool, str | None]:
148
+ """Probe ``(host, port)`` by trying to ``bind()`` it across every family.
149
+
150
+ Resolves ``host`` via ``getaddrinfo`` with ``AF_UNSPEC`` and walks
151
+ each (family, sockaddr) candidate so the preflight matches the
152
+ same dual-stack behaviour as ``websockets.serve`` / ``aiohttp``.
153
+ A literal ``::1`` or an IPv6-resolving ``localhost`` is therefore
154
+ probed against the right address family rather than being
155
+ misreported by an ``AF_INET``-only socket.
156
+
157
+ Returns ``(available, info)``:
158
+
159
+ - ``(True, None)``: at least one address family bound successfully.
160
+ (Some IPv6 stacks fail with ``EADDRNOTAVAIL`` on hosts without a
161
+ configured v6 interface; the gateway also tolerates that, so we
162
+ report "ready" if any candidate succeeded.)
163
+ - ``(False, "pid <N>, <cmd>")``: at least one candidate reported
164
+ ``EADDRINUSE``. We short-circuit on the first one because the
165
+ gateway will collide with the holder regardless of any other
166
+ family that may have been free.
167
+ - ``(False, None)``: same as above, but ``lsof`` could not identify
168
+ the holder (or is unavailable on this platform).
169
+ - ``(False, "bind error: <reason>")``: every candidate failed for
170
+ a non-``EADDRINUSE`` reason (typically ``EADDRNOTAVAIL`` for an
171
+ IP that is not assigned to any local interface, or ``EACCES`` on
172
+ a privileged port without permission). Distinguishing this from
173
+ "in use" prevents users from chasing a phantom process when the
174
+ real issue is the bind address.
175
+ """
176
+ try:
177
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
178
+ except socket.gaierror as exc:
179
+ return (False, f"{_BIND_ERROR_PREFIX}getaddrinfo failed: {exc}")
180
+
181
+ last_error: str | None = None
182
+ bound_at_least_once = False
183
+ for family, socktype, proto, _canonname, sockaddr in infos:
184
+ sock = socket.socket(family, socktype, proto)
185
+ # Mirror ``asyncio.create_server``'s default behaviour on POSIX:
186
+ # the gateway sets SO_REUSEADDR=1, so a port in TIME_WAIT after
187
+ # a recent gateway restart would NOT actually block a fresh
188
+ # bind. Without this option the preflight would misreport such
189
+ # a port as IN USE and exit non-zero, even though the gateway
190
+ # itself would start cleanly. SO_REUSEADDR does not let the
191
+ # bind succeed when another process is currently LISTENing on
192
+ # the port (POSIX semantics), so the EADDRINUSE branch below
193
+ # still fires for genuine collisions.
194
+ if hasattr(socket, "SO_REUSEADDR"):
195
+ try:
196
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
197
+ except OSError:
198
+ # Some platforms reject SO_REUSEADDR for certain socket
199
+ # types; fall through and try the bind anyway.
200
+ pass
201
+ try:
202
+ try:
203
+ sock.bind(sockaddr)
204
+ except OSError as exc:
205
+ if exc.errno == errno.EADDRINUSE:
206
+ # Mirror gateway behaviour: an EADDRINUSE on any
207
+ # candidate family means the gateway will collide.
208
+ return (False, _try_get_port_holder(port))
209
+ reason = exc.strerror or (
210
+ os.strerror(exc.errno)
211
+ if exc.errno is not None
212
+ else str(exc)
213
+ )
214
+ last_error = f"{_BIND_ERROR_PREFIX}{reason}"
215
+ else:
216
+ bound_at_least_once = True
217
+ finally:
218
+ sock.close()
219
+
220
+ if bound_at_least_once:
221
+ return (True, None)
222
+ return (False, last_error)
223
+
224
+
225
+ def _try_get_port_holder(port: int) -> str | None:
226
+ """Best-effort lookup of the process holding ``port`` via ``lsof``.
227
+
228
+ Returns ``"pid <N>, <cmd>"`` on success, or ``None`` if ``lsof`` is
229
+ not installed, the call fails, or the port is not in fact held (for
230
+ example, the bind failure was due to a permission error rather than
231
+ EADDRINUSE).
232
+ """
233
+ if shutil.which("lsof") is None:
234
+ return None
235
+ try:
236
+ result = subprocess.run(
237
+ ["lsof", f"-iTCP:{port}", "-sTCP:LISTEN", "-Fpcn"],
238
+ capture_output=True,
239
+ text=True,
240
+ timeout=2,
241
+ check=False,
242
+ )
243
+ except (OSError, subprocess.SubprocessError):
244
+ return None
245
+ if result.returncode != 0 or not result.stdout:
246
+ return None
247
+ pid: str | None = None
248
+ cmd: str | None = None
249
+ for line in result.stdout.splitlines():
250
+ if line.startswith("p"):
251
+ pid = line[1:]
252
+ elif line.startswith("c"):
253
+ cmd = line[1:]
254
+ if pid and cmd:
255
+ return f"pid {pid}, {cmd}"
256
+ if pid:
257
+ return f"pid {pid}"
258
+ return None
259
+
260
+
261
+ def _format_port_status(available: bool, holder: str | None) -> str:
262
+ if available:
263
+ return "AVAILABLE"
264
+ if holder is None:
265
+ return "IN USE"
266
+ if holder.startswith(_BIND_ERROR_PREFIX):
267
+ # Don't say "IN USE" for non-EADDRINUSE bind failures
268
+ # (EADDRNOTAVAIL, EACCES, etc.). Surface the actual reason
269
+ # instead so the user does not chase a phantom process.
270
+ reason = holder.removeprefix(_BIND_ERROR_PREFIX)
271
+ return f"BIND ERROR ({reason})"
272
+ return f"IN USE ({holder})"
273
+
274
+
275
+ _TCP_PORT_RANGE = range(0, 65536)
276
+
277
+
278
+ def _validate_port_value(raw: str, var: str) -> tuple[int | None, str]:
279
+ """Parse ``raw`` as a TCP port, returning ``(port, source_or_error)``.
280
+
281
+ Returns ``(int_value, var)`` for a valid in-range integer (0..65535
282
+ inclusive — ``0`` lets the OS pick, which the gateway may not
283
+ actually want but is at least bind-able). Returns ``(None, "<var>=
284
+ <raw> (...)")`` otherwise; the caller treats that as a blocking
285
+ issue rather than silently falling through to a default.
286
+
287
+ Both branches matter for the preflight: ``socket.bind()`` raises
288
+ ``OverflowError`` for values outside the TCP port range, so without
289
+ this validation ``--check`` would crash with a stack trace instead
290
+ of producing the diagnostic report it exists to produce.
291
+ """
292
+ try:
293
+ value = int(raw)
294
+ except ValueError:
295
+ return (None, f"{var}={raw!r} (not an integer)")
296
+ if value not in _TCP_PORT_RANGE:
297
+ return (None, f"{var}={raw!r} (out of TCP port range 0-65535)")
298
+ return (value, var)
299
+
300
+
301
+ def _resolve_ws_port() -> tuple[int | None, str]:
302
+ """Resolve the WebSocket port using the same precedence as ``gateway.py``.
303
+
304
+ Mirrors ``int(os.getenv("WS_PORT", os.getenv("PORT", "8765")))`` from
305
+ ``gateway.py`` so the preflight checks the port the gateway will
306
+ actually bind, not a hard-coded default. See ``_validate_port_value``
307
+ for the validation rules; on success returns ``(port, "WS_PORT")``
308
+ or ``(port, "PORT")``, otherwise ``(None, "<var>=<raw> (...)")``.
309
+ """
310
+ for var in ("WS_PORT", "PORT"):
311
+ raw = os.getenv(var)
312
+ if raw is None:
313
+ continue
314
+ return _validate_port_value(raw, var)
315
+ return (8765, "default")
316
+
317
+
318
+ def _resolve_capture_port() -> tuple[int | None, str]:
319
+ """Resolve the HTTP capture port using ``gateway.py``'s precedence.
320
+
321
+ Mirrors ``int(os.getenv("CAPTURE_PORT", "8766"))``. See
322
+ ``_validate_port_value`` for the validation rules.
323
+ """
324
+ raw = os.getenv("CAPTURE_PORT")
325
+ if raw is None:
326
+ return (8766, "default")
327
+ return _validate_port_value(raw, "CAPTURE_PORT")
328
+
329
+
330
+ # Exact query parameter names that are always redacted in preflight
331
+ # output. Compared lowercased.
332
+ _SECRET_QUERY_KEYS = frozenset(
333
+ {
334
+ "access_token",
335
+ "api_key",
336
+ "apikey",
337
+ "auth",
338
+ "auth_token",
339
+ "key",
340
+ "password",
341
+ "secret",
342
+ "sig",
343
+ "signature",
344
+ "token",
345
+ }
346
+ )
347
+
348
+ # Suffix-based heuristic for redacting provider-specific signed-URL
349
+ # parameters without enumerating every variant. Matches things like
350
+ # ``X-Amz-Signature``, ``X-Amz-Security-Token``, ``X-Goog-Signature``,
351
+ # Azure SAS ``sig`` (already covered by the exact set), generic
352
+ # ``*_token`` / ``*-secret`` patterns, etc. Compared lowercased.
353
+ _SECRET_QUERY_KEY_SUFFIXES = (
354
+ "signature",
355
+ "token",
356
+ "secret",
357
+ "password",
358
+ "credential",
359
+ "credentials",
360
+ )
361
+
362
+
363
+ def _is_secret_query_key(key: str) -> bool:
364
+ lower = key.lower()
365
+ if lower in _SECRET_QUERY_KEYS:
366
+ return True
367
+ return any(lower.endswith(suffix) for suffix in _SECRET_QUERY_KEY_SUFFIXES)
368
+
369
+
370
+ def _redact_url_secrets(url: str) -> str:
371
+ """Mask userinfo and secret-looking query params in ``url``.
372
+
373
+ The preflight output is meant to be safe to paste into a public
374
+ issue or log, so any in-URL credential is replaced before
375
+ printing:
376
+
377
+ - ``https://user:pass@host/path`` → ``https://***:***@host/path``
378
+ - ``?token=abc&page=1`` → ``?token=%2A%2A%2Aredacted%2A%2A%2A&page=1``
379
+ (only keys in ``_SECRET_QUERY_KEYS`` are touched; non-secret
380
+ params keep their value)
381
+
382
+ Non-credential structure (scheme, host, port, path, fragment,
383
+ non-secret query keys) is preserved so users can still see what
384
+ the gateway is actually configured to call. Inputs that fail to
385
+ parse are returned unchanged so the preflight never crashes on a
386
+ malformed value.
387
+ """
388
+ try:
389
+ parsed = urlparse(url)
390
+ except (ValueError, TypeError):
391
+ return url
392
+
393
+ netloc = parsed.netloc
394
+ if "@" in netloc:
395
+ # Strip userinfo and replace with a fixed placeholder. Don't
396
+ # try to preserve the username — the username alone can leak
397
+ # information and the structural info we care about (host,
398
+ # port) lives after the @.
399
+ _userinfo, _, host_part = netloc.rpartition("@")
400
+ netloc = f"***:***@{host_part}"
401
+
402
+ query = parsed.query
403
+ if query:
404
+ try:
405
+ params = parse_qsl(query, keep_blank_values=True)
406
+ except ValueError:
407
+ params = None
408
+ if params is not None:
409
+ redacted = [
410
+ (k, "***redacted***") if _is_secret_query_key(k) else (k, v)
411
+ for k, v in params
412
+ ]
413
+ query = urlencode(redacted)
414
+
415
+ return urlunparse(
416
+ (parsed.scheme, netloc, parsed.path, parsed.params, query, parsed.fragment)
417
+ )
418
+
419
+
420
+ def _load_dotenv() -> None:
421
+ """Lazy ``.env`` loader exposed as a single attachable seam.
422
+
423
+ Wrapping ``python-dotenv`` here keeps two properties:
424
+
425
+ 1. ``import stackchan_mcp.cli`` stays side-effect free (the
426
+ ``dotenv`` import only happens when the gateway / preflight is
427
+ actually invoked).
428
+ 2. Tests can ``monkeypatch.setattr(cli, "_load_dotenv", ...)`` to
429
+ prevent the real ``find_dotenv()`` walking up to the developer's
430
+ ``gateway/.env`` and contaminating environment-isolation tests.
431
+ """
432
+ from dotenv import load_dotenv
433
+
434
+ load_dotenv()
435
+
436
+
437
+ def _run_ownership_check() -> int:
438
+ """Print the current ownership lock status and exit cleanly."""
439
+ from .ownership import is_pid_alive, read_lock
440
+
441
+ info = read_lock()
442
+ if info is None:
443
+ print("no current owner")
444
+ print("ownership preflight: ready")
445
+ print("Result: ready. Exit 0.")
446
+ elif is_pid_alive(info["pid"]):
447
+ fields = [
448
+ f"owner_id={info['owner_id']}",
449
+ f"pid={info['pid']}",
450
+ f"start_ts={info['start_ts']}",
451
+ f"host={info['host']}",
452
+ ]
453
+ for key in ("mode", "http_endpoint", "started_by"):
454
+ if key in info:
455
+ fields.append(f"{key}={info[key]}")
456
+ print(" ".join(fields))
457
+ else:
458
+ print(f"stale lock found: pid {info['pid']} not alive")
459
+ return 0
460
+
461
+
462
+ # Default Homebrew prefixes that ship libopus.dylib on macOS. Apple
463
+ # Silicon installs default to ``/opt/homebrew``; Intel Macs use
464
+ # ``/usr/local``. Keeping both keeps the helper portable across
465
+ # contributor machines.
466
+ _HOMEBREW_LIB_DIRS = ("/opt/homebrew/lib", "/usr/local/lib")
467
+
468
+
469
+ def _ensure_libopus_findable() -> None:
470
+ """Make libopus reachable to opuslib's ``ctypes.find_library`` on macOS.
471
+
472
+ ``opuslib.api`` calls ``ctypes.util.find_library("opus")`` at
473
+ import time. On macOS that walks ``DYLD_LIBRARY_PATH`` plus a
474
+ couple of system-default directories — but not Homebrew's
475
+ ``/opt/homebrew/lib`` (Apple Silicon) or ``/usr/local/lib`` (Intel),
476
+ so a vanilla ``brew install opus`` lands a working libopus that
477
+ opuslib still cannot find. Users then see ``Could not find Opus
478
+ library`` even though the dylib is on disk.
479
+
480
+ Prepend any Homebrew-style lib directories that exist so the next
481
+ ``find_library`` call (triggered by the lazy ``import opuslib``
482
+ inside :func:`audio_utils.encode_opus_frames`) succeeds. We
483
+ deliberately *prepend* and skip duplicates so an explicit
484
+ ``DYLD_LIBRARY_PATH`` set by the operator (e.g. for a custom build
485
+ of libopus) keeps priority. No-op on non-macOS hosts.
486
+ """
487
+ if platform.system() != "Darwin":
488
+ return
489
+
490
+ existing = os.environ.get("DYLD_LIBRARY_PATH", "")
491
+ paths: list[str] = [p for p in existing.split(":") if p]
492
+
493
+ prepended: list[str] = []
494
+ for candidate in _HOMEBREW_LIB_DIRS:
495
+ if candidate in paths:
496
+ continue
497
+ if not os.path.isdir(candidate):
498
+ continue
499
+ prepended.append(candidate)
500
+
501
+ if not prepended:
502
+ return
503
+
504
+ os.environ["DYLD_LIBRARY_PATH"] = ":".join(prepended + paths)
505
+ logger.debug(
506
+ "Prepended Homebrew lib dirs to DYLD_LIBRARY_PATH so opuslib "
507
+ "can find libopus: %s",
508
+ prepended,
509
+ )
510
+
511
+
512
+ def _run_preflight() -> int:
513
+ """Run preflight diagnostics. Returns the desired process exit code.
514
+
515
+ Output is intentionally fixed-width and grep-friendly. Exit 0 means
516
+ "ready to run"; non-zero means at least one blocking issue (currently
517
+ only port unavailability). Missing optional configuration is reported
518
+ but does not fail the check, mirroring how the gateway itself only
519
+ warns about a missing ``STACKCHAN_TOKEN``.
520
+ """
521
+ _load_dotenv()
522
+ _ensure_libopus_findable()
523
+
524
+ issues = 0
525
+ print(f"stackchan-mcp {__version__} preflight")
526
+ print()
527
+
528
+ # --- Configuration ------------------------------------------------------
529
+ print("Configuration:")
530
+ token = os.getenv("STACKCHAN_TOKEN") or os.getenv("BEARER_TOKEN")
531
+ if token:
532
+ print(" STACKCHAN_TOKEN set (***redacted***)")
533
+ else:
534
+ print(" STACKCHAN_TOKEN not set (gateway will accept any client)")
535
+
536
+ mcp_http_allowed_hosts = os.getenv("MCP_HTTP_ALLOWED_HOSTS", "")
537
+ if mcp_http_allowed_hosts:
538
+ print(f" MCP_HTTP_ALLOWED_HOSTS {mcp_http_allowed_hosts}")
539
+ else:
540
+ print(" MCP_HTTP_ALLOWED_HOSTS not set")
541
+
542
+ vision_host = os.getenv("VISION_HOST", "")
543
+ capture_port_raw = os.getenv("CAPTURE_PORT", "8766")
544
+ if vision_host:
545
+ print(f" VISION_HOST {vision_host}")
546
+ else:
547
+ print(" VISION_HOST not set")
548
+
549
+ vision_url_explicit = os.getenv("VISION_URL", "")
550
+ if vision_url_explicit:
551
+ print(f" VISION_URL {_redact_url_secrets(vision_url_explicit)}")
552
+ elif vision_host:
553
+ # Derived URL has no userinfo or query params, so no redaction
554
+ # needed; the host part is shown as-is by design (it is the IP
555
+ # the user has configured for capture).
556
+ derived = f"http://{vision_host}:{capture_port_raw}/capture"
557
+ print(f" VISION_URL (derived) {derived}")
558
+ else:
559
+ print(
560
+ " VISION_URL not set "
561
+ "(set VISION_HOST or VISION_URL for take_photo)"
562
+ )
563
+
564
+ if os.getenv("VISION_TOKEN"):
565
+ print(" VISION_TOKEN set (***redacted***)")
566
+ else:
567
+ print(" VISION_TOKEN not set (will reuse STACKCHAN_TOKEN)")
568
+
569
+ audio_hook_url = os.getenv("STACKCHAN_AUDIO_HOOK_URL", "")
570
+ if audio_hook_url:
571
+ print(
572
+ f" STACKCHAN_AUDIO_HOOK_URL {_redact_url_secrets(audio_hook_url)}"
573
+ )
574
+ if os.getenv("STACKCHAN_AUDIO_HOOK_TOKEN"):
575
+ print(" STACKCHAN_AUDIO_HOOK_TOKEN set (***redacted***)")
576
+ else:
577
+ print(
578
+ " STACKCHAN_AUDIO_HOOK_TOKEN not set "
579
+ "(will reuse STACKCHAN_TOKEN)"
580
+ )
581
+ else:
582
+ print(
583
+ " STACKCHAN_AUDIO_HOOK_URL not set "
584
+ "(device-driven listen capture disabled)"
585
+ )
586
+
587
+ # --- Ports --------------------------------------------------------------
588
+ print()
589
+ print("Ports:")
590
+ host = os.getenv("HOST", "0.0.0.0")
591
+ mcp_http_host = os.getenv("MCP_HTTP_HOST", "127.0.0.1")
592
+ ws_port, ws_source = _resolve_ws_port()
593
+ cap_port, cap_source = _resolve_capture_port()
594
+ raw_mcp_http_port = os.getenv("MCP_HTTP_PORT")
595
+ if raw_mcp_http_port is None:
596
+ mcp_http_port, mcp_http_source = (8767, "default")
597
+ else:
598
+ mcp_http_port, mcp_http_source = _validate_port_value(
599
+ raw_mcp_http_port,
600
+ "MCP_HTTP_PORT",
601
+ )
602
+
603
+ if ws_port is None:
604
+ print(f" ws://{host}:??? INVALID ({ws_source})")
605
+ issues += 1
606
+ if cap_port is None:
607
+ print(f" http://{host}:??? INVALID ({cap_source})")
608
+ issues += 1
609
+ if mcp_http_port is None:
610
+ print(f" http://{mcp_http_host}:???/mcp INVALID ({mcp_http_source})")
611
+ issues += 1
612
+
613
+ if (
614
+ ws_port is not None
615
+ and cap_port is not None
616
+ and ws_port == cap_port
617
+ and ws_port != 0
618
+ ):
619
+ # The gateway runs WebSocket and HTTP capture as separate
620
+ # listeners; binding the WebSocket server first will then make
621
+ # the HTTP bind fail. Independent _check_port probes can't see
622
+ # this on their own (each one binds-and-releases), so we surface
623
+ # the conflict explicitly.
624
+ #
625
+ # Port 0 is excluded: each ``bind((host, 0))`` asks the OS for a
626
+ # fresh ephemeral port, so two listeners both configured with 0
627
+ # do NOT collide (this is exactly the configuration the existing
628
+ # gateway tests use).
629
+ print(
630
+ f" WS_PORT ({ws_source}) and CAPTURE_PORT ({cap_source}) "
631
+ f"both resolve to {ws_port}; the gateway needs distinct ports."
632
+ )
633
+ issues += 1
634
+
635
+ if mcp_http_port is not None:
636
+ for label, other_port, other_source in (
637
+ ("WS_PORT", ws_port, ws_source),
638
+ ("CAPTURE_PORT", cap_port, cap_source),
639
+ ):
640
+ if other_port is None or mcp_http_port == 0 or other_port == 0:
641
+ continue
642
+ if mcp_http_port == other_port:
643
+ print(
644
+ f" MCP_HTTP_PORT ({mcp_http_source}) and {label} "
645
+ f"({other_source}) both resolve to {mcp_http_port}; "
646
+ "the daemon needs distinct listener ports."
647
+ )
648
+ issues += 1
649
+
650
+ if ws_port is not None:
651
+ ws_available, ws_holder = _check_port(host, ws_port)
652
+ print(
653
+ f" ws://{host}:{ws_port} "
654
+ f"{_format_port_status(ws_available, ws_holder)}"
655
+ )
656
+ if not ws_available:
657
+ issues += 1
658
+
659
+ if cap_port is not None:
660
+ cap_available, cap_holder = _check_port(host, cap_port)
661
+ print(
662
+ f" http://{host}:{cap_port} "
663
+ f"{_format_port_status(cap_available, cap_holder)}"
664
+ )
665
+ if not cap_available:
666
+ issues += 1
667
+
668
+ if mcp_http_port is not None:
669
+ from .http_server import validate_bind_safety
670
+
671
+ mcp_available, mcp_holder = _check_port(mcp_http_host, mcp_http_port)
672
+ print(
673
+ f" http://{mcp_http_host}:{mcp_http_port}/mcp "
674
+ f"{_format_port_status(mcp_available, mcp_holder)}"
675
+ )
676
+ if not mcp_available:
677
+ issues += 1
678
+ try:
679
+ validate_bind_safety(mcp_http_host, token)
680
+ except ValueError as exc:
681
+ print(f" MCP HTTP bind safety: BLOCKED ({exc})")
682
+ issues += 1
683
+
684
+ # --- Result -------------------------------------------------------------
685
+ print()
686
+ if issues == 0:
687
+ print("Result: ready. Exit 0.")
688
+ return 0
689
+ plural = "s" if issues > 1 else ""
690
+ print(f"Result: {issues} issue{plural}. Exit 1.")
691
+ return 1
692
+
693
+
694
+ async def _run(*, advertise_mdns: bool = True) -> None:
695
+ """Start both the ESP32 WebSocket server and the stdio MCP server."""
696
+ import signal
697
+
698
+ from .event_log import rotate_old_entries
699
+ from .gateway import get_gateway
700
+ from .notify_config import load_notify_config
701
+ from .stdio_server import run_stdio_server
702
+
703
+ notify_config = load_notify_config()
704
+ gateway = get_gateway()
705
+ esp32 = getattr(gateway, "esp32", None)
706
+ set_notify_config = getattr(esp32, "set_notify_config", None)
707
+ if callable(set_notify_config):
708
+ set_notify_config(notify_config)
709
+
710
+ loop = asyncio.get_running_loop()
711
+ main_task = asyncio.current_task()
712
+
713
+ def _handle_sigterm() -> None:
714
+ if main_task and not main_task.done():
715
+ main_task.cancel()
716
+
717
+ if sys.platform != "win32":
718
+ loop.add_signal_handler(signal.SIGTERM, _handle_sigterm)
719
+
720
+ # Prune stale stackchan-event log entries only when the JSONL path is
721
+ # explicitly enabled. With the default all-OFF notify config, gateway
722
+ # startup must not create or rewrite any persistent event-log files.
723
+ if notify_config.jsonl_enabled:
724
+ rotate_old_entries(path=notify_config.jsonl_path)
725
+
726
+ await gateway.start(advertise_mdns=advertise_mdns)
727
+ logger.info("Gateway started, waiting for ESP32 connections...")
728
+
729
+ try:
730
+ # Run stdio MCP server (blocks until MCP client disconnects)
731
+ await run_stdio_server(notify_config=notify_config)
732
+ except asyncio.CancelledError:
733
+ logger.info("Received termination signal, shutting down...")
734
+ finally:
735
+ await gateway.stop()
736
+
737
+
738
+ def _configure_gateway_startup() -> None:
739
+ """Load runtime configuration and logging for gateway startup paths."""
740
+ _load_dotenv()
741
+ _ensure_libopus_findable()
742
+
743
+ logging.basicConfig(
744
+ level=logging.INFO,
745
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
746
+ )
747
+
748
+
749
+ def _acquire_startup_lock(
750
+ *,
751
+ mode: "LockMode" = _STDIO_TRANSPORT,
752
+ http_endpoint: str | None = None,
753
+ started_by: str | None = None,
754
+ ) -> "LockInfo":
755
+ """Claim the gateway ownership lock and register normal cleanup."""
756
+ from .ownership import (
757
+ OwnershipError,
758
+ acquire_lock,
759
+ generate_owner_id,
760
+ release_lock_if_owner,
761
+ )
762
+
763
+ owner_id = generate_owner_id()
764
+ try:
765
+ if mode == _STDIO_TRANSPORT and http_endpoint is None and started_by is None:
766
+ info = acquire_lock(owner_id)
767
+ else:
768
+ info = acquire_lock(
769
+ owner_id,
770
+ mode=mode,
771
+ http_endpoint=http_endpoint,
772
+ started_by=started_by,
773
+ )
774
+ except OwnershipError as exc:
775
+ print(str(exc), file=sys.stderr)
776
+ sys.exit(1)
777
+
778
+ try:
779
+ print(
780
+ "stackchan-mcp: acquired ownership lock "
781
+ f"(owner_id={info['owner_id']}, pid={info['pid']})",
782
+ file=sys.stderr,
783
+ )
784
+ atexit.register(release_lock_if_owner, info)
785
+ except BaseException:
786
+ release_lock_if_owner(info)
787
+ raise
788
+
789
+ return info
790
+
791
+
792
+ def _prepare_stdio_startup() -> "LockInfo":
793
+ """Prepare the existing stdio gateway flow without changing its lock shape."""
794
+ _configure_gateway_startup()
795
+ return _acquire_startup_lock()
796
+
797
+
798
+ def _run_stdio_gateway(*, advertise_mdns: bool = True) -> None:
799
+ """Run the existing stdio MCP gateway flow."""
800
+ from .ownership import release_lock_if_owner
801
+
802
+ info = _prepare_stdio_startup()
803
+ try:
804
+ try:
805
+ asyncio.run(_run(advertise_mdns=advertise_mdns))
806
+ except KeyboardInterrupt:
807
+ pass
808
+ finally:
809
+ release_lock_if_owner(info)
810
+
811
+
812
+ def _resolve_mcp_http_endpoint() -> tuple[str, int]:
813
+ """Resolve the Streamable HTTP daemon endpoint from environment."""
814
+ host = os.getenv("MCP_HTTP_HOST", "127.0.0.1")
815
+ raw_port = os.getenv("MCP_HTTP_PORT", "8767")
816
+ port, source = _validate_port_value(raw_port, "MCP_HTTP_PORT")
817
+ if port is None:
818
+ print(f"stackchan-mcp: invalid MCP_HTTP_PORT: {source}", file=sys.stderr)
819
+ sys.exit(1)
820
+ return host, port
821
+
822
+
823
+ async def _run_streamable_http_daemon(
824
+ *,
825
+ host: str,
826
+ port: int,
827
+ owner_id: str,
828
+ token: str | None,
829
+ advertise_mdns: bool,
830
+ ) -> None:
831
+ """Run the Streamable HTTP MCP daemon until the ASGI server exits."""
832
+ import uvicorn
833
+
834
+ from .event_log import rotate_old_entries
835
+ from .gateway import get_gateway
836
+ from .notify_config import load_notify_config
837
+ from .http_server import build_app, make_dispatch_fn
838
+ from .queue import CommandQueue
839
+
840
+ notify_config = load_notify_config()
841
+ if notify_config.jsonl_enabled:
842
+ rotate_old_entries(path=notify_config.jsonl_path)
843
+
844
+ gateway = get_gateway()
845
+ esp32 = getattr(gateway, "esp32", None)
846
+ set_notify_config = getattr(esp32, "set_notify_config", None)
847
+ if callable(set_notify_config):
848
+ set_notify_config(notify_config)
849
+ queue = CommandQueue()
850
+ app = build_app(
851
+ queue,
852
+ gateway=gateway,
853
+ owner_id=owner_id,
854
+ host=host,
855
+ port=port,
856
+ token=token,
857
+ dispatch_fn=make_dispatch_fn(gateway),
858
+ notify_config=notify_config,
859
+ )
860
+ config = uvicorn.Config(
861
+ app,
862
+ host=host,
863
+ port=port,
864
+ log_level="info",
865
+ lifespan="on",
866
+ )
867
+ server = uvicorn.Server(config)
868
+
869
+ await gateway.start(advertise_mdns=advertise_mdns)
870
+ logger.info(
871
+ "Streamable HTTP MCP daemon starting on http://%s:%d/mcp",
872
+ host,
873
+ port,
874
+ )
875
+ try:
876
+ await server.serve()
877
+ finally:
878
+ await gateway.stop()
879
+
880
+
881
+ def _run_streamable_http_placeholder(*, advertise_mdns: bool = True) -> None:
882
+ """Run the Streamable HTTP MCP daemon."""
883
+ from .ownership import release_lock_if_owner
884
+ from .http_server import (
885
+ get_configured_token,
886
+ validate_bind_safety,
887
+ )
888
+
889
+ _configure_gateway_startup()
890
+ host, port = _resolve_mcp_http_endpoint()
891
+ token = get_configured_token()
892
+ try:
893
+ validate_bind_safety(host, token)
894
+ except ValueError as exc:
895
+ print(str(exc), file=sys.stderr)
896
+ sys.exit(1)
897
+
898
+ info: LockInfo | None = None
899
+ try:
900
+ info = _acquire_startup_lock(
901
+ mode=_STREAMABLE_HTTP_TRANSPORT,
902
+ http_endpoint=f"{host}:{port}",
903
+ started_by="cli-serve",
904
+ )
905
+ try:
906
+ asyncio.run(
907
+ _run_streamable_http_daemon(
908
+ host=host,
909
+ port=port,
910
+ owner_id=info["owner_id"],
911
+ token=token,
912
+ advertise_mdns=advertise_mdns,
913
+ )
914
+ )
915
+ except KeyboardInterrupt:
916
+ pass
917
+ finally:
918
+ if info is not None:
919
+ release_lock_if_owner(info)
920
+
921
+
922
+ def main(argv: list[str] | None = None) -> None:
923
+ """Console-script entry point.
924
+
925
+ Parses ``--help`` / ``--version`` / ``--check`` / ``--preflight`` early
926
+ (without starting the server), then dispatches either the legacy
927
+ zero-subcommand stdio flow or the ``serve`` subcommand. Side effects
928
+ are intentionally scoped below argument parsing so that
929
+ ``import stackchan_mcp`` stays clean.
930
+ """
931
+ parser = _build_arg_parser()
932
+ # argparse exits with status 0 on --help / --version before reaching
933
+ # any of the gateway start-up below, which is the intended behaviour.
934
+ args = parser.parse_args(argv)
935
+
936
+ if args.check:
937
+ sys.exit(_run_ownership_check())
938
+
939
+ if args.preflight:
940
+ # ``_run_preflight`` loads ``.env`` itself; do not double-load
941
+ # via the path below.
942
+ sys.exit(_run_preflight())
943
+
944
+ if args.command is None:
945
+ _run_stdio_gateway(advertise_mdns=not args.no_mdns)
946
+ return
947
+
948
+ if args.command == "serve":
949
+ advertise_mdns = not (args.no_mdns or getattr(args, "serve_no_mdns", False))
950
+ if args.transport == _STDIO_TRANSPORT:
951
+ _run_stdio_gateway(advertise_mdns=advertise_mdns)
952
+ return
953
+ _run_streamable_http_placeholder(advertise_mdns=advertise_mdns)
954
+ return
955
+
956
+
957
+ if __name__ == "__main__":
958
+ main()