stackchan-mcp 0.9.1__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/PKG-INFO +1 -1
  2. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/pyproject.toml +1 -1
  3. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/mdns_advertiser.py +179 -9
  4. stackchan_mcp-0.10.0/tests/test_mdns_advertiser.py +913 -0
  5. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/uv.lock +1 -1
  6. stackchan_mcp-0.9.1/tests/test_mdns_advertiser.py +0 -379
  7. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/.env.example +0 -0
  8. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/.gitignore +0 -0
  9. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/AGENTS.md +0 -0
  10. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/LICENSE +0 -0
  11. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/LICENSE-THIRD-PARTY +0 -0
  12. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/README.md +0 -0
  13. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/__init__.py +0 -0
  14. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/__main__.py +0 -0
  15. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
  16. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/audio_input_hook.py +0 -0
  17. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/audio_stream.py +0 -0
  18. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/capture_server.py +0 -0
  19. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/cli.py +0 -0
  20. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/esp32_client.py +0 -0
  21. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/event_log.py +0 -0
  22. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/gateway.py +0 -0
  23. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/handlers/__init__.py +0 -0
  24. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/handlers/audio.py +0 -0
  25. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/handlers/camera.py +0 -0
  26. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/handlers/robot.py +0 -0
  27. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/http_server.py +0 -0
  28. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/mcp_router.py +0 -0
  29. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/notify.example.yml +0 -0
  30. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/notify_config.py +0 -0
  31. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/ownership.py +0 -0
  32. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/protocol.py +0 -0
  33. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/queue.py +0 -0
  34. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/server.py +0 -0
  35. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/stdio_server.py +0 -0
  36. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/__init__.py +0 -0
  37. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  38. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/base.py +0 -0
  39. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  40. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  41. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/orchestrator.py +0 -0
  42. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/tools.py +0 -0
  43. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/__init__.py +0 -0
  44. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  45. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/base.py +0 -0
  46. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/orchestrator.py +0 -0
  47. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/voicevox.py +0 -0
  48. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/_audio_fixtures.py +0 -0
  49. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/conftest.py +0 -0
  50. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_audio_input_hook.py +0 -0
  51. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_audio_stream.py +0 -0
  52. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_audio_utils.py +0 -0
  53. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_capture_server.py +0 -0
  54. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_cli.py +0 -0
  55. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_esp32_client.py +0 -0
  56. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_event_dispatch.py +0 -0
  57. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_event_log.py +0 -0
  58. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_gateway.py +0 -0
  59. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_http_server.py +0 -0
  60. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_mcp_router.py +0 -0
  61. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_notify_config.py +0 -0
  62. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_orchestrator.py +0 -0
  63. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_ownership.py +0 -0
  64. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_protocol.py +0 -0
  65. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_send_pcm_audio.py +0 -0
  66. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_send_pcm_stream.py +0 -0
  67. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_stackchan_event.py +0 -0
  68. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_stdio_server.py +0 -0
  69. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_stt_audio_utils.py +0 -0
  70. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_stt_framework.py +0 -0
  71. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_stt_orchestrator.py +0 -0
  72. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.0}/tests/test_tts_framework.py +0 -0
  73. {stackchan_mcp-0.9.1 → stackchan_mcp-0.10.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.9.1
3
+ Version: 0.10.0
4
4
  Summary: Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP.
5
5
  Project-URL: Homepage, https://github.com/kisaragi-mochi/stackchan-mcp
6
6
  Project-URL: Repository, https://github.com/kisaragi-mochi/stackchan-mcp
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackchan-mcp"
3
- version = "0.9.1"
3
+ version = "0.10.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"
@@ -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 Exception:
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