stackchan-mcp 0.9.1__tar.gz → 0.11.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.9.1 → stackchan_mcp-0.11.0}/PKG-INFO +5 -1
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/pyproject.toml +13 -2
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/mdns_advertiser.py +179 -9
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/ownership.py +142 -15
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stdio_server.py +246 -9
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/__init__.py +7 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/base.py +5 -0
- stackchan_mcp-0.11.0/stackchan_mcp/tts/emoji_expression.py +80 -0
- stackchan_mcp-0.11.0/stackchan_mcp/tts/irodori.py +358 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/orchestrator.py +187 -13
- stackchan_mcp-0.11.0/tests/test_emoji_expression.py +44 -0
- stackchan_mcp-0.11.0/tests/test_irodori.py +438 -0
- stackchan_mcp-0.11.0/tests/test_mdns_advertiser.py +913 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_orchestrator.py +303 -21
- stackchan_mcp-0.11.0/tests/test_ownership.py +434 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stdio_server.py +293 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_tts_framework.py +77 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/uv.lock +50 -2
- stackchan_mcp-0.9.1/tests/test_mdns_advertiser.py +0 -379
- stackchan_mcp-0.9.1/tests/test_ownership.py +0 -92
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/.env.example +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/.gitignore +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/AGENTS.md +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/LICENSE +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/LICENSE-THIRD-PARTY +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/README.md +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/audio_input_hook.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/cli.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/esp32_client.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/event_log.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/http_server.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/notify.example.yml +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/notify_config.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/queue.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/orchestrator.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_audio_input_hook.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_audio_utils.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_cli.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_esp32_client.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_event_dispatch.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_event_log.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_http_server.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_notify_config.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_send_pcm_audio.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_send_pcm_stream.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stackchan_event.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stt_orchestrator.py +0 -0
- {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_voicevox.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackchan-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -41,6 +41,10 @@ Requires-Dist: opuslib>=3; extra == 'stt-openai'
|
|
|
41
41
|
Provides-Extra: tts
|
|
42
42
|
Requires-Dist: httpx>=0.27; extra == 'tts'
|
|
43
43
|
Requires-Dist: opuslib>=3; extra == 'tts'
|
|
44
|
+
Provides-Extra: tts-irodori
|
|
45
|
+
Requires-Dist: httpx>=0.27; extra == 'tts-irodori'
|
|
46
|
+
Requires-Dist: miniaudio>=1.59; extra == 'tts-irodori'
|
|
47
|
+
Requires-Dist: opuslib>=3; extra == 'tts-irodori'
|
|
44
48
|
Provides-Extra: tts-voicevox
|
|
45
49
|
Requires-Dist: httpx>=0.27; extra == 'tts-voicevox'
|
|
46
50
|
Requires-Dist: opuslib>=3; extra == 'tts-voicevox'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "stackchan-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.0"
|
|
4
4
|
description = "Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -51,7 +51,7 @@ dependencies = [
|
|
|
51
51
|
[project.optional-dependencies]
|
|
52
52
|
# Phase 4 TTS — see Issue #70.
|
|
53
53
|
# Concrete engines (VOICEVOX, Irodori) consume these libraries:
|
|
54
|
-
# * httpx — VOICEVOX HTTP engine client
|
|
54
|
+
# * httpx — VOICEVOX / Irodori HTTP engine client
|
|
55
55
|
# * opuslib — Opus encoding for the device's audio decoder
|
|
56
56
|
# `tts-voicevox` is a no-op alias provided so users can declare intent
|
|
57
57
|
# explicitly; the VOICEVOX engine itself is an external HTTP process and
|
|
@@ -63,6 +63,17 @@ tts = [
|
|
|
63
63
|
tts-voicevox = [
|
|
64
64
|
"stackchan-mcp[tts]",
|
|
65
65
|
]
|
|
66
|
+
# Irodori is an external HTTP synthesis service that returns MP3 (see
|
|
67
|
+
# Issue #286). On top of the base `[tts]` extra (httpx + opuslib) it adds
|
|
68
|
+
# `miniaudio` to decode the MP3 response to PCM. miniaudio is a small,
|
|
69
|
+
# self-contained C extension (MIT) with prebuilt wheels for Windows,
|
|
70
|
+
# macOS (x86_64 + arm64), and Linux, so it installs without a system
|
|
71
|
+
# audio library — unlike a ffmpeg/libav binding — which keeps the
|
|
72
|
+
# Windows wheel path (and the CI windows-smoke job) simple.
|
|
73
|
+
tts-irodori = [
|
|
74
|
+
"stackchan-mcp[tts]",
|
|
75
|
+
"miniaudio>=1.59",
|
|
76
|
+
]
|
|
66
77
|
|
|
67
78
|
# Phase 4 STT — see Issue #91.
|
|
68
79
|
# The base `stt` extra carries `opuslib` for decoding the device's
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import ipaddress
|
|
6
7
|
import logging
|
|
7
8
|
import socket
|
|
@@ -238,6 +239,26 @@ def _resolve_concrete_host_ipv4_addresses(host: str) -> list[str]:
|
|
|
238
239
|
return _select_advertised_addresses([(address, None) for address in addresses])
|
|
239
240
|
|
|
240
241
|
|
|
242
|
+
def _resolve_addresses_for_host(host: str) -> list[str]:
|
|
243
|
+
"""Resolve advertised addresses for ``host`` using the same logic as
|
|
244
|
+
:func:`build_advertisement`.
|
|
245
|
+
|
|
246
|
+
Wildcard hosts (``0.0.0.0`` / ``*`` / ``""``) enumerate every usable
|
|
247
|
+
non-loopback IPv4 address on the machine; concrete hosts only resolve
|
|
248
|
+
addresses for that specific host. The refresh loop calls this helper so
|
|
249
|
+
its comparison set matches what a fresh ``build_advertisement(...)``
|
|
250
|
+
would actually register — without this, a host started with a concrete
|
|
251
|
+
HOST in a multi-NIC / Tailscale environment would see every poll as an
|
|
252
|
+
"address change" against an unrelated extra interface and churn the
|
|
253
|
+
registration on every refresh interval.
|
|
254
|
+
"""
|
|
255
|
+
return (
|
|
256
|
+
_enumerate_usable_ipv4_addresses()
|
|
257
|
+
if _is_wildcard_host(host)
|
|
258
|
+
else _resolve_concrete_host_ipv4_addresses(host)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
241
262
|
def build_advertisement(
|
|
242
263
|
*,
|
|
243
264
|
host: str,
|
|
@@ -253,11 +274,7 @@ def build_advertisement(
|
|
|
253
274
|
return None
|
|
254
275
|
|
|
255
276
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
256
|
-
addresses = (
|
|
257
|
-
_enumerate_usable_ipv4_addresses()
|
|
258
|
-
if _is_wildcard_host(host)
|
|
259
|
-
else _resolve_concrete_host_ipv4_addresses(host)
|
|
260
|
-
)
|
|
277
|
+
addresses = _resolve_addresses_for_host(host)
|
|
261
278
|
if not addresses:
|
|
262
279
|
logger.warning(
|
|
263
280
|
"mDNS advertisement skipped: no usable non-loopback IPv4 address "
|
|
@@ -280,15 +297,57 @@ def build_advertisement(
|
|
|
280
297
|
class MdnsAdvertiser:
|
|
281
298
|
"""Registers the gateway's WebSocket endpoint via mDNS/DNS-SD."""
|
|
282
299
|
|
|
283
|
-
def __init__(self) -> None:
|
|
300
|
+
def __init__(self, *, refresh_interval: float = 30.0) -> None:
|
|
301
|
+
if refresh_interval < 10.0 or refresh_interval > 300.0:
|
|
302
|
+
raise ValueError("refresh_interval must be between 10.0 and 300.0 seconds")
|
|
303
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
284
304
|
self._zeroconf: Any | None = None
|
|
285
305
|
self._service_info: Any | None = None
|
|
306
|
+
self._refresh_task: asyncio.Task | None = None
|
|
307
|
+
self._last_advertised_addresses: tuple[str, ...] | None = None
|
|
308
|
+
self._refresh_interval: float = refresh_interval
|
|
309
|
+
self._start_args: dict[str, Any] | None = None
|
|
286
310
|
|
|
287
311
|
async def start(self, *, host: str, port: int, path: str = "/") -> None:
|
|
312
|
+
task = self._refresh_task
|
|
313
|
+
if task is not None and task is not asyncio.current_task():
|
|
314
|
+
task.cancel()
|
|
315
|
+
try:
|
|
316
|
+
await asyncio.wait_for(task, timeout=1.0)
|
|
317
|
+
except (asyncio.CancelledError, TimeoutError):
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
async with self._lock:
|
|
321
|
+
self._refresh_task = None
|
|
322
|
+
if self._zeroconf is not None:
|
|
323
|
+
await self._close_zeroconf_locked()
|
|
324
|
+
self._start_args = {"host": host, "port": port, "path": path}
|
|
325
|
+
try:
|
|
326
|
+
advertisement = await self._start_locked(host=host, port=port, path=path)
|
|
327
|
+
except Exception:
|
|
328
|
+
self._start_args = None
|
|
329
|
+
self._last_advertised_addresses = None
|
|
330
|
+
raise
|
|
331
|
+
if advertisement is None:
|
|
332
|
+
self._start_args = None
|
|
333
|
+
self._last_advertised_addresses = None
|
|
334
|
+
return
|
|
335
|
+
self._last_advertised_addresses = tuple(advertisement.parsed_addresses)
|
|
336
|
+
self._refresh_task = asyncio.create_task(self._refresh_loop())
|
|
337
|
+
|
|
338
|
+
async def _start_locked(
|
|
339
|
+
self, *, host: str, port: int, path: str = "/"
|
|
340
|
+
) -> MdnsAdvertisement | None:
|
|
288
341
|
advertisement = build_advertisement(host=host, port=port, path=path)
|
|
289
342
|
if advertisement is None:
|
|
290
|
-
return
|
|
343
|
+
return None
|
|
291
344
|
|
|
345
|
+
await self._register_advertisement_locked(advertisement)
|
|
346
|
+
return advertisement
|
|
347
|
+
|
|
348
|
+
async def _register_advertisement_locked(
|
|
349
|
+
self, advertisement: MdnsAdvertisement
|
|
350
|
+
) -> None:
|
|
292
351
|
AsyncZeroconf, ServiceInfo = _load_zeroconf_classes()
|
|
293
352
|
# Constrain zeroconf to the IPv4 interfaces we actually advertise on.
|
|
294
353
|
# The default ``InterfaceChoice.All`` makes zeroconf bind a socket on
|
|
@@ -309,7 +368,15 @@ class MdnsAdvertiser:
|
|
|
309
368
|
)
|
|
310
369
|
try:
|
|
311
370
|
await zeroconf.async_register_service(info, allow_name_change=True)
|
|
312
|
-
except
|
|
371
|
+
except BaseException:
|
|
372
|
+
# Catch BaseException (not just Exception) so that an
|
|
373
|
+
# ``asyncio.CancelledError`` arriving mid-registration —
|
|
374
|
+
# e.g. an external ``stop()`` or a double-``start()`` racing
|
|
375
|
+
# this registration — still closes the partially-constructed
|
|
376
|
+
# ``AsyncZeroconf``. Otherwise the bound multicast sockets
|
|
377
|
+
# and any partial registration would leak past the cancelled
|
|
378
|
+
# task. ``raise`` preserves the cancellation semantics for
|
|
379
|
+
# the surrounding ``async with self._lock`` caller.
|
|
313
380
|
await zeroconf.async_close()
|
|
314
381
|
raise
|
|
315
382
|
self._zeroconf = zeroconf
|
|
@@ -326,13 +393,28 @@ class MdnsAdvertiser:
|
|
|
326
393
|
advertisement.service_name,
|
|
327
394
|
)
|
|
328
395
|
logger.info(
|
|
329
|
-
"mDNS advertising %s on port %d with addresses %s",
|
|
396
|
+
"mDNS advertising %s on port %d with addresses %s reason=register",
|
|
330
397
|
registered_name,
|
|
331
398
|
advertisement.port,
|
|
332
399
|
", ".join(advertisement.parsed_addresses),
|
|
333
400
|
)
|
|
334
401
|
|
|
335
402
|
async def stop(self) -> None:
|
|
403
|
+
task = self._refresh_task
|
|
404
|
+
if task is not None and task is not asyncio.current_task():
|
|
405
|
+
task.cancel()
|
|
406
|
+
try:
|
|
407
|
+
await asyncio.wait_for(task, timeout=1.0)
|
|
408
|
+
except (asyncio.CancelledError, TimeoutError):
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
async with self._lock:
|
|
412
|
+
await self._close_zeroconf_locked()
|
|
413
|
+
self._refresh_task = None
|
|
414
|
+
self._last_advertised_addresses = None
|
|
415
|
+
self._start_args = None
|
|
416
|
+
|
|
417
|
+
async def _close_zeroconf_locked(self) -> None:
|
|
336
418
|
zeroconf = self._zeroconf
|
|
337
419
|
info = self._service_info
|
|
338
420
|
self._zeroconf = None
|
|
@@ -345,3 +427,91 @@ class MdnsAdvertiser:
|
|
|
345
427
|
await zeroconf.async_unregister_service(info)
|
|
346
428
|
finally:
|
|
347
429
|
await zeroconf.async_close()
|
|
430
|
+
|
|
431
|
+
async def _reconfigure(self) -> None:
|
|
432
|
+
async with self._lock:
|
|
433
|
+
if self._start_args is None:
|
|
434
|
+
return
|
|
435
|
+
old_addresses = self._last_advertised_addresses
|
|
436
|
+
advertisement = build_advertisement(**self._start_args)
|
|
437
|
+
if advertisement is None:
|
|
438
|
+
logger.info(
|
|
439
|
+
"mDNS reconfigure skipped: build_advertisement returned None "
|
|
440
|
+
"(transient empty address state); refresh loop continues "
|
|
441
|
+
"reason=transient_empty_address_state old=%s",
|
|
442
|
+
old_addresses,
|
|
443
|
+
)
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
# Clear the cached advertised set BEFORE closing so that even a
|
|
447
|
+
# failure inside ``_close_zeroconf_locked()`` itself (e.g. the
|
|
448
|
+
# old interface has vanished mid-cycle and ``async_unregister``
|
|
449
|
+
# / ``async_close`` raise) still leaves the refresh loop in a
|
|
450
|
+
# "must retry" state. If the subsequent close raises, or the
|
|
451
|
+
# re-registration raises, or the host IP later reverts to
|
|
452
|
+
# ``old_addresses``, the refresh loop's
|
|
453
|
+
# ``current != _last_advertised_addresses`` check would
|
|
454
|
+
# otherwise compare against the stale value and stay quiet —
|
|
455
|
+
# the advertisement would remain dead until a manual restart.
|
|
456
|
+
# Setting this to ``None`` here guarantees the next refresh
|
|
457
|
+
# tick observes a divergence and retries registration
|
|
458
|
+
# regardless of which address state the host happens to be in.
|
|
459
|
+
self._last_advertised_addresses = None
|
|
460
|
+
await self._close_zeroconf_locked()
|
|
461
|
+
await self._register_advertisement_locked(advertisement)
|
|
462
|
+
new_addresses = tuple(advertisement.parsed_addresses)
|
|
463
|
+
self._last_advertised_addresses = new_addresses
|
|
464
|
+
registered_name = getattr(
|
|
465
|
+
self._service_info,
|
|
466
|
+
"name",
|
|
467
|
+
advertisement.service_name,
|
|
468
|
+
)
|
|
469
|
+
logger.info(
|
|
470
|
+
"mDNS reconfigured: reason=address_changed old=%s new=%s "
|
|
471
|
+
"registered_name=%s",
|
|
472
|
+
old_addresses,
|
|
473
|
+
new_addresses,
|
|
474
|
+
registered_name,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
async def _refresh_loop(self) -> None:
|
|
478
|
+
while True:
|
|
479
|
+
try:
|
|
480
|
+
await asyncio.sleep(self._refresh_interval)
|
|
481
|
+
start_args = self._start_args
|
|
482
|
+
if start_args is None:
|
|
483
|
+
continue
|
|
484
|
+
host = start_args["host"]
|
|
485
|
+
current_addresses = tuple(_resolve_addresses_for_host(host))
|
|
486
|
+
old_addresses = self._last_advertised_addresses
|
|
487
|
+
if current_addresses == old_addresses:
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
logger.info(
|
|
491
|
+
"mDNS refresh observed address change: reason=address_changed "
|
|
492
|
+
"old=%s new=%s debounce=started",
|
|
493
|
+
old_addresses,
|
|
494
|
+
current_addresses,
|
|
495
|
+
)
|
|
496
|
+
await asyncio.sleep(self._refresh_interval)
|
|
497
|
+
start_args = self._start_args
|
|
498
|
+
if start_args is None:
|
|
499
|
+
continue
|
|
500
|
+
host = start_args["host"]
|
|
501
|
+
confirmed_addresses = tuple(_resolve_addresses_for_host(host))
|
|
502
|
+
if confirmed_addresses != current_addresses:
|
|
503
|
+
logger.info(
|
|
504
|
+
"mDNS refresh dropped transient address change: "
|
|
505
|
+
"reason=debounce_mismatch old=%s first=%s second=%s",
|
|
506
|
+
old_addresses,
|
|
507
|
+
current_addresses,
|
|
508
|
+
confirmed_addresses,
|
|
509
|
+
)
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
await self._reconfigure()
|
|
513
|
+
except asyncio.CancelledError:
|
|
514
|
+
return
|
|
515
|
+
except Exception:
|
|
516
|
+
logger.exception("mDNS refresh loop error; continuing")
|
|
517
|
+
continue
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import math
|
|
6
7
|
import os
|
|
7
8
|
import socket
|
|
9
|
+
import subprocess
|
|
8
10
|
import sys
|
|
9
11
|
import uuid
|
|
10
12
|
from datetime import datetime, timezone
|
|
@@ -13,6 +15,8 @@ from typing import Literal, TypedDict
|
|
|
13
15
|
|
|
14
16
|
LOCK_DIR = Path.home() / ".stackchan-mcp"
|
|
15
17
|
LOCK_PATH = LOCK_DIR / "owner.lock"
|
|
18
|
+
PROC_START_TOLERANCE_SECONDS = 2.0
|
|
19
|
+
_PROC_ROOT = Path("/proc")
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
LockMode = Literal["stdio", "streamable-http"]
|
|
@@ -29,6 +33,7 @@ class LockInfo(_BaseLockInfo, total=False):
|
|
|
29
33
|
mode: LockMode
|
|
30
34
|
http_endpoint: str | None
|
|
31
35
|
started_by: str | None
|
|
36
|
+
proc_start_epoch: float
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
class OwnershipError(RuntimeError):
|
|
@@ -65,6 +70,101 @@ def is_pid_alive(pid: int) -> bool:
|
|
|
65
70
|
return True
|
|
66
71
|
|
|
67
72
|
|
|
73
|
+
def get_process_start_time(pid: int) -> float | None:
|
|
74
|
+
"""Return the kernel-reported process start time as epoch seconds.
|
|
75
|
+
|
|
76
|
+
Supported on Linux (``/proc``) and macOS (``ps lstart``). On other
|
|
77
|
+
platforms — notably Windows — this returns ``None``, so the identity
|
|
78
|
+
check is skipped and stale-lock detection falls back to the PID-only
|
|
79
|
+
liveness check (the pre-#253 behavior). Windows process-identity
|
|
80
|
+
verification is deferred per Issue #253's acceptance criteria.
|
|
81
|
+
"""
|
|
82
|
+
if pid <= 0:
|
|
83
|
+
return None
|
|
84
|
+
if sys.platform.startswith("linux"):
|
|
85
|
+
return _get_process_start_time_linux(pid)
|
|
86
|
+
if sys.platform == "darwin":
|
|
87
|
+
return _get_process_start_time_macos(pid)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_process_start_time_linux(pid: int) -> float | None:
|
|
92
|
+
try:
|
|
93
|
+
stat_text = (_PROC_ROOT / str(pid) / "stat").read_text(encoding="utf-8")
|
|
94
|
+
proc_stat_text = (_PROC_ROOT / "stat").read_text(encoding="utf-8")
|
|
95
|
+
ticks_per_second = os.sysconf("SC_CLK_TCK")
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
if not isinstance(ticks_per_second, int) or ticks_per_second <= 0:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
after_comm = stat_text.rsplit(")", 1)[1].strip()
|
|
104
|
+
fields = after_comm.split()
|
|
105
|
+
start_ticks = int(fields[19])
|
|
106
|
+
except (IndexError, ValueError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
boot_time: int | None = None
|
|
110
|
+
for line in proc_stat_text.splitlines():
|
|
111
|
+
parts = line.split()
|
|
112
|
+
if len(parts) == 2 and parts[0] == "btime":
|
|
113
|
+
try:
|
|
114
|
+
boot_time = int(parts[1])
|
|
115
|
+
except ValueError:
|
|
116
|
+
return None
|
|
117
|
+
break
|
|
118
|
+
if boot_time is None:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
return float(boot_time) + (float(start_ticks) / float(ticks_per_second))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_process_start_time_macos(pid: int) -> float | None:
|
|
125
|
+
env = os.environ.copy()
|
|
126
|
+
env["LC_ALL"] = "C"
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["ps", "-o", "lstart=", "-p", str(pid)],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
check=False,
|
|
132
|
+
env=env,
|
|
133
|
+
text=True,
|
|
134
|
+
)
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
output = result.stdout.strip()
|
|
139
|
+
if not output:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
line = output.splitlines()[0].strip()
|
|
143
|
+
try:
|
|
144
|
+
started = datetime.strptime(line, "%a %b %d %H:%M:%S %Y")
|
|
145
|
+
except ValueError:
|
|
146
|
+
return None
|
|
147
|
+
return started.timestamp()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _coerce_proc_start_epoch(value: object) -> float | None:
|
|
151
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
152
|
+
return None
|
|
153
|
+
proc_start_epoch = float(value)
|
|
154
|
+
if not math.isfinite(proc_start_epoch):
|
|
155
|
+
return None
|
|
156
|
+
return proc_start_epoch
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _process_identity_matches(pid: int, expected_start: float | None) -> bool:
|
|
160
|
+
if expected_start is None:
|
|
161
|
+
return True
|
|
162
|
+
actual_start = get_process_start_time(pid)
|
|
163
|
+
if actual_start is None:
|
|
164
|
+
return True
|
|
165
|
+
return abs(actual_start - expected_start) <= PROC_START_TOLERANCE_SECONDS
|
|
166
|
+
|
|
167
|
+
|
|
68
168
|
def _is_pid_alive_windows(pid: int) -> bool:
|
|
69
169
|
import ctypes
|
|
70
170
|
import ctypes.wintypes
|
|
@@ -152,6 +252,11 @@ def read_lock(path: Path = LOCK_PATH) -> LockInfo | None:
|
|
|
152
252
|
if started_by is None or isinstance(started_by, str):
|
|
153
253
|
info["started_by"] = started_by
|
|
154
254
|
|
|
255
|
+
if "proc_start_epoch" in raw:
|
|
256
|
+
proc_start_epoch = _coerce_proc_start_epoch(raw["proc_start_epoch"])
|
|
257
|
+
if proc_start_epoch is not None:
|
|
258
|
+
info["proc_start_epoch"] = proc_start_epoch
|
|
259
|
+
|
|
155
260
|
return info
|
|
156
261
|
|
|
157
262
|
|
|
@@ -178,10 +283,11 @@ def acquire_lock(
|
|
|
178
283
|
) -> LockInfo:
|
|
179
284
|
"""Acquire the ownership lock. Raise OwnershipError on refuse.
|
|
180
285
|
|
|
181
|
-
The default stdio-mode call writes the original #177 lock
|
|
182
|
-
|
|
183
|
-
compatible
|
|
184
|
-
diagnostics without changing the
|
|
286
|
+
The default stdio-mode call writes the original #177 lock base fields
|
|
287
|
+
plus additive process identity metadata. Older lock readers remain
|
|
288
|
+
compatible because the new field is optional JSON. Daemon transports
|
|
289
|
+
can attach optional metadata for diagnostics without changing the
|
|
290
|
+
atomic hardlink claim.
|
|
185
291
|
"""
|
|
186
292
|
if mode not in ("stdio", "streamable-http"):
|
|
187
293
|
raise ValueError(f"unsupported lock mode: {mode!r}")
|
|
@@ -195,25 +301,39 @@ def acquire_lock(
|
|
|
195
301
|
existing = read_lock(path)
|
|
196
302
|
if existing is not None:
|
|
197
303
|
if is_pid_alive(existing["pid"]):
|
|
198
|
-
|
|
199
|
-
"
|
|
200
|
-
|
|
201
|
-
|
|
304
|
+
if _process_identity_matches(
|
|
305
|
+
existing["pid"], existing.get("proc_start_epoch")
|
|
306
|
+
):
|
|
307
|
+
raise OwnershipError(
|
|
308
|
+
"stackchan-mcp: device already owned by "
|
|
309
|
+
f"{existing['owner_id']} "
|
|
310
|
+
f"(pid {existing['pid']}, since {existing['start_ts']})"
|
|
311
|
+
)
|
|
312
|
+
print(
|
|
313
|
+
"stackchan-mcp: removed stale lock from recycled pid "
|
|
314
|
+
f"{existing['pid']}",
|
|
315
|
+
file=sys.stderr,
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
print(
|
|
319
|
+
"stackchan-mcp: removed stale lock from dead pid "
|
|
320
|
+
f"{existing['pid']}",
|
|
321
|
+
file=sys.stderr,
|
|
202
322
|
)
|
|
203
|
-
print(
|
|
204
|
-
f"stackchan-mcp: removed stale lock from dead pid {existing['pid']}",
|
|
205
|
-
file=sys.stderr,
|
|
206
|
-
)
|
|
207
323
|
path.unlink(missing_ok=True)
|
|
208
324
|
elif path.exists():
|
|
209
325
|
path.unlink()
|
|
210
326
|
|
|
327
|
+
pid = os.getpid()
|
|
328
|
+
proc_start_epoch = get_process_start_time(pid)
|
|
211
329
|
info: LockInfo = {
|
|
212
330
|
"owner_id": owner_id,
|
|
213
|
-
"pid":
|
|
331
|
+
"pid": pid,
|
|
214
332
|
"start_ts": _now_iso(),
|
|
215
333
|
"host": socket.gethostname(),
|
|
216
334
|
}
|
|
335
|
+
if proc_start_epoch is not None:
|
|
336
|
+
info["proc_start_epoch"] = proc_start_epoch
|
|
217
337
|
if mode != "stdio":
|
|
218
338
|
info["mode"] = mode
|
|
219
339
|
if http_endpoint is not None:
|
|
@@ -246,8 +366,9 @@ def release_lock_if_owner(info: LockInfo, path: Path = LOCK_PATH) -> bool:
|
|
|
246
366
|
"""Remove the lock file only if it still belongs to ``info``.
|
|
247
367
|
|
|
248
368
|
Returns ``True`` if the lock was removed, ``False`` if the on-disk
|
|
249
|
-
lock has a different ``owner_id`` / ``pid`` / ``start_ts``
|
|
250
|
-
longer exists. This is the owner-scoped
|
|
369
|
+
lock has a different ``owner_id`` / ``pid`` / ``start_ts`` /
|
|
370
|
+
``proc_start_epoch`` or no longer exists. This is the owner-scoped
|
|
371
|
+
counterpart to
|
|
251
372
|
:func:`release_lock` and is intended for cleanup paths (``finally``
|
|
252
373
|
blocks, ``atexit.register``) where the caller may have lost ownership
|
|
253
374
|
between claim and cleanup — for example after the gateway exited and
|
|
@@ -263,6 +384,12 @@ def release_lock_if_owner(info: LockInfo, path: Path = LOCK_PATH) -> bool:
|
|
|
263
384
|
or existing.get("start_ts") != info.get("start_ts")
|
|
264
385
|
):
|
|
265
386
|
return False
|
|
387
|
+
if (
|
|
388
|
+
"proc_start_epoch" in existing
|
|
389
|
+
and "proc_start_epoch" in info
|
|
390
|
+
and existing["proc_start_epoch"] != info["proc_start_epoch"]
|
|
391
|
+
):
|
|
392
|
+
return False
|
|
266
393
|
try:
|
|
267
394
|
path.unlink()
|
|
268
395
|
return True
|