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.
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/PKG-INFO +1 -1
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/pyproject.toml +1 -1
- stackchan_mcp-0.3.0/stackchan_mcp/__init__.py +12 -0
- stackchan_mcp-0.3.0/stackchan_mcp/cli.py +540 -0
- stackchan_mcp-0.3.0/tests/test_cli.py +735 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/uv.lock +1 -1
- stackchan_mcp-0.2.0/stackchan_mcp/__init__.py +0 -7
- stackchan_mcp-0.2.0/stackchan_mcp/cli.py +0 -57
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/.env.example +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/.gitignore +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/LICENSE +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/README.md +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/esp32_client.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/stdio_server.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_esp32_client.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.2.0 → stackchan_mcp-0.3.0}/tests/test_protocol.py +0 -0
- {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.
|
|
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
|
|
@@ -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()
|