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.
- stackchan_mcp/__init__.py +81 -0
- stackchan_mcp/__main__.py +12 -0
- stackchan_mcp/_libs/SOURCES.md +130 -0
- stackchan_mcp/_libs/opus.dll +0 -0
- stackchan_mcp/audio_input_hook.py +432 -0
- stackchan_mcp/audio_stream.py +162 -0
- stackchan_mcp/capture_server.py +469 -0
- stackchan_mcp/cli.py +958 -0
- stackchan_mcp/esp32_client.py +983 -0
- stackchan_mcp/event_log.py +189 -0
- stackchan_mcp/gateway.py +274 -0
- stackchan_mcp/handlers/__init__.py +7 -0
- stackchan_mcp/handlers/audio.py +21 -0
- stackchan_mcp/handlers/camera.py +25 -0
- stackchan_mcp/handlers/robot.py +52 -0
- stackchan_mcp/http_server.py +398 -0
- stackchan_mcp/mcp_router.py +126 -0
- stackchan_mcp/mdns_advertiser.py +347 -0
- stackchan_mcp/notify.example.yml +21 -0
- stackchan_mcp/notify_config.py +235 -0
- stackchan_mcp/ownership.py +270 -0
- stackchan_mcp/protocol.py +95 -0
- stackchan_mcp/queue.py +191 -0
- stackchan_mcp/server.py +28 -0
- stackchan_mcp/stdio_server.py +1365 -0
- stackchan_mcp/stt/__init__.py +62 -0
- stackchan_mcp/stt/audio_utils.py +102 -0
- stackchan_mcp/stt/base.py +94 -0
- stackchan_mcp/stt/faster_whisper.py +217 -0
- stackchan_mcp/stt/openai_whisper.py +177 -0
- stackchan_mcp/stt/orchestrator.py +568 -0
- stackchan_mcp/tools.py +82 -0
- stackchan_mcp/tts/__init__.py +62 -0
- stackchan_mcp/tts/audio_utils.py +177 -0
- stackchan_mcp/tts/base.py +86 -0
- stackchan_mcp/tts/orchestrator.py +688 -0
- stackchan_mcp/tts/voicevox.py +184 -0
- stackchan_mcp-0.9.1.dist-info/METADATA +324 -0
- stackchan_mcp-0.9.1.dist-info/RECORD +43 -0
- stackchan_mcp-0.9.1.dist-info/WHEEL +5 -0
- stackchan_mcp-0.9.1.dist-info/entry_points.txt +2 -0
- stackchan_mcp-0.9.1.dist-info/licenses/LICENSE +39 -0
- 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()
|