stackchan-mcp 0.13.0__tar.gz → 0.14.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 (81) hide show
  1. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/PKG-INFO +1 -1
  2. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/pyproject.toml +1 -1
  3. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/capture_server.py +26 -14
  4. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/cli.py +41 -8
  5. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/esp32_client.py +61 -0
  6. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_capture_server.py +69 -0
  7. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_cli.py +72 -5
  8. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_esp32_client.py +311 -2
  9. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/uv.lock +1 -1
  10. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/.env.example +0 -0
  11. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/.gitignore +0 -0
  12. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/AGENTS.md +0 -0
  13. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/LICENSE +0 -0
  14. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/LICENSE-THIRD-PARTY +0 -0
  15. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/README.md +0 -0
  16. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/__init__.py +0 -0
  17. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/__main__.py +0 -0
  18. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
  19. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/audio_input_hook.py +0 -0
  20. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/audio_stream.py +0 -0
  21. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/event_log.py +0 -0
  22. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/follow_pose_stream.py +0 -0
  23. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/gateway.py +0 -0
  24. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/__init__.py +0 -0
  25. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/audio.py +0 -0
  26. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/camera.py +0 -0
  27. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/robot.py +0 -0
  28. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/http_server.py +0 -0
  29. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/mcp_router.py +0 -0
  30. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/mdns_advertiser.py +0 -0
  31. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/notify.example.yml +0 -0
  32. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/notify_config.py +0 -0
  33. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/ownership.py +0 -0
  34. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/protocol.py +0 -0
  35. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/queue.py +0 -0
  36. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/server.py +0 -0
  37. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stdio_server.py +0 -0
  38. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/__init__.py +0 -0
  39. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  40. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/base.py +0 -0
  41. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  42. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  43. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/orchestrator.py +0 -0
  44. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tools.py +0 -0
  45. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/__init__.py +0 -0
  46. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  47. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/base.py +0 -0
  48. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/edge_tts.py +0 -0
  49. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/emoji_expression.py +0 -0
  50. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/irodori.py +0 -0
  51. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/orchestrator.py +0 -0
  52. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/voicevox.py +0 -0
  53. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/user_defaults.py +0 -0
  54. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/_audio_fixtures.py +0 -0
  55. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/conftest.py +0 -0
  56. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_audio_input_hook.py +0 -0
  57. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_audio_stream.py +0 -0
  58. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_audio_utils.py +0 -0
  59. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_emoji_expression.py +0 -0
  60. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_event_dispatch.py +0 -0
  61. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_event_log.py +0 -0
  62. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_follow_pose_stream.py +0 -0
  63. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_gateway.py +0 -0
  64. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_http_server.py +0 -0
  65. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_irodori.py +0 -0
  66. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_mcp_router.py +0 -0
  67. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_mdns_advertiser.py +0 -0
  68. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_notify_config.py +0 -0
  69. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_orchestrator.py +0 -0
  70. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_ownership.py +0 -0
  71. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_protocol.py +0 -0
  72. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_send_pcm_audio.py +0 -0
  73. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_send_pcm_stream.py +0 -0
  74. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stackchan_event.py +0 -0
  75. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stdio_server.py +0 -0
  76. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stt_audio_utils.py +0 -0
  77. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stt_framework.py +0 -0
  78. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stt_orchestrator.py +0 -0
  79. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_tts_framework.py +0 -0
  80. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_user_defaults.py +0 -0
  81. {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.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.13.0
3
+ Version: 0.14.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.13.0"
3
+ version = "0.14.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,6 +51,7 @@ import json
51
51
  import logging
52
52
  import os
53
53
  import secrets
54
+ import sys
54
55
  import time
55
56
  from dataclasses import dataclass
56
57
  from typing import TYPE_CHECKING, AsyncIterator
@@ -142,7 +143,11 @@ async def handle_capture(request: web.Request) -> web.Response:
142
143
 
143
144
  async for part in reader:
144
145
  if part.name == "question":
145
- question = (await part.read()).decode("utf-8")
146
+ # Decode defensively: a malformed (non-UTF-8) question field must
147
+ # not turn a photo upload into a 500. The question is advisory
148
+ # metadata, so replacing undecodable bytes is preferable to
149
+ # dropping the whole capture.
150
+ question = (await part.read()).decode("utf-8", errors="replace")
146
151
  elif part.name == "file":
147
152
  timestamp = int(time.time() * 1000)
148
153
  filename = f"capture_{timestamp}.jpg"
@@ -445,19 +450,26 @@ def create_capture_app(
445
450
  dispatches to. May be ``None`` for tests of /capture alone; /pcm
446
451
  will return 503 in that case.
447
452
  """
448
- # ``client_max_size=0`` disables aiohttp's per-request body size
449
- # cap (default 1 MiB). The /pcm endpoint legitimately streams
450
- # arbitrarily long PCM utterances (multi-minute TTS, live audio
451
- # mixes); a 1 MiB cap would silently cut a chunked-transfer
452
- # producer off mid-stream once its cumulative body exceeded that
453
- # limit observed in practice with a 200-second TTS push, which
454
- # aborted around 36 s in (~2 MiB of source-rate PCM through the
455
- # transfer-encoding pipe). The handler itself enforces no separate
456
- # cap; back-pressure comes from the device-side Opus push rate
457
- # inside ``send_pcm_stream``, which is the right place for it.
458
- # /capture only receives JPEG snapshots from the ESP32 (well under
459
- # 1 MiB each) so removing the cap costs it nothing.
460
- app = web.Application(client_max_size=0)
453
+ # Raise aiohttp's per-request body cap (default 1 MiB) effectively to
454
+ # unlimited. The /pcm endpoint legitimately streams arbitrarily long PCM
455
+ # utterances (multi-minute TTS, live audio mixes); a 1 MiB cap would
456
+ # silently cut a chunked-transfer producer off mid-stream once its
457
+ # cumulative body exceeded that limit observed in practice with a
458
+ # 200-second TTS push, which aborted around 36 s in (~2 MiB of
459
+ # source-rate PCM through the transfer-encoding pipe).
460
+ #
461
+ # Use ``sys.maxsize`` rather than ``0``. Although ``0`` disables the cap
462
+ # for ``request.read()`` / ``request.post()`` (both guard with
463
+ # ``if 0 < max_size``), aiohttp's multipart reader does NOT special-case
464
+ # ``0``: ``BodyPartReader.read()`` compares ``len(data) > client_max_size``
465
+ # directly (aiohttp >= 3.14), so a client_max_size of 0 propagated into
466
+ # ``request.multipart()`` rejects ANY non-empty multipart field with
467
+ # HTTP 413. /capture reads its ``question`` form field via ``part.read()``,
468
+ # so with ``client_max_size=0`` every photo upload carrying a non-empty
469
+ # question 413s ("Failed to upload photo"). A large finite value avoids
470
+ # that trap while keeping /pcm effectively unbounded. /capture's real
471
+ # size limit is the explicit CAPTURE_MAX_BYTES check in handle_capture.
472
+ app = web.Application(client_max_size=sys.maxsize)
461
473
  app[CAPTURE_TOKEN_KEY] = capture_token
462
474
  app[AVATAR_SETS_KEY] = {}
463
475
  app[AVATAR_SETS_LOCK_KEY] = asyncio.Lock()
@@ -438,9 +438,20 @@ def _run_ownership_check() -> int:
438
438
  """Print the current ownership lock status and exit cleanly."""
439
439
  from .ownership import is_pid_alive, read_lock
440
440
 
441
- info = read_lock()
441
+ # Load ``.env`` first so that a WS_PORT / PORT defined only there resolves
442
+ # the same per-port lock the running gateway claims. Startup loads ``.env``
443
+ # before ``_acquire_startup_lock`` (via ``_run_stdio_gateway`` /
444
+ # ``_run_streamable_http_placeholder``), so ``--check`` must match it —
445
+ # otherwise a gateway owning ``owner-18765.lock`` would be inspected against
446
+ # the default ``owner-8765.lock`` and wrongly reported ready. Mirrors how
447
+ # ``--preflight`` loads ``.env`` inside ``_run_preflight``.
448
+ _load_dotenv()
449
+ # Inspect the per-WS_PORT lock this gateway would claim (not the legacy
450
+ # machine-global owner.lock), so the preflight reflects the actual port.
451
+ lock_path = _ws_port_lock_path()
452
+ info = read_lock(lock_path)
442
453
  if info is None:
443
- print("no current owner")
454
+ print(f"no current owner (lock: {lock_path.name})")
444
455
  print("ownership preflight: ready")
445
456
  print("Result: ready. Exit 0.")
446
457
  elif is_pid_alive(info["pid"]):
@@ -750,6 +761,24 @@ def _configure_gateway_startup() -> None:
750
761
  log_user_defaults_startup()
751
762
 
752
763
 
764
+ def _ws_port_lock_path():
765
+ """Per-WS_PORT ownership lock path.
766
+
767
+ 複数機体対応: ownership lock を machine-global な ``owner.lock`` でなく
768
+ WS_PORT 単位に scope する。 lock は「同一 device を 2 つの gateway が
769
+ 奪い合わない」 ための機構だが、 1 device = 1 gateway = 1 WS ポートで
770
+ 複数機体を同時に動かす構成では global lock が 2 台目以降を誤って弾く。
771
+ ポート単位なら N gateway が共存でき、 同一ポート二重起動は従来通り弾ける。
772
+ WS_PORT 未解決時は従来の global パスにフォールバック。
773
+ """
774
+ from .ownership import LOCK_DIR
775
+
776
+ ws_port, _ = _resolve_ws_port()
777
+ return LOCK_DIR / (
778
+ f"owner-{ws_port}.lock" if ws_port is not None else "owner.lock"
779
+ )
780
+
781
+
753
782
  def _acquire_startup_lock(
754
783
  *,
755
784
  mode: "LockMode" = _STDIO_TRANSPORT,
@@ -764,13 +793,16 @@ def _acquire_startup_lock(
764
793
  release_lock_if_owner,
765
794
  )
766
795
 
796
+ lock_path = _ws_port_lock_path()
797
+
767
798
  owner_id = generate_owner_id()
768
799
  try:
769
800
  if mode == _STDIO_TRANSPORT and http_endpoint is None and started_by is None:
770
- info = acquire_lock(owner_id)
801
+ info = acquire_lock(owner_id, path=lock_path)
771
802
  else:
772
803
  info = acquire_lock(
773
804
  owner_id,
805
+ path=lock_path,
774
806
  mode=mode,
775
807
  http_endpoint=http_endpoint,
776
808
  started_by=started_by,
@@ -782,12 +814,13 @@ def _acquire_startup_lock(
782
814
  try:
783
815
  print(
784
816
  "stackchan-mcp: acquired ownership lock "
785
- f"(owner_id={info['owner_id']}, pid={info['pid']})",
817
+ f"(owner_id={info['owner_id']}, pid={info['pid']}, "
818
+ f"lock={lock_path.name})",
786
819
  file=sys.stderr,
787
820
  )
788
- atexit.register(release_lock_if_owner, info)
821
+ atexit.register(release_lock_if_owner, info, lock_path)
789
822
  except BaseException:
790
- release_lock_if_owner(info)
823
+ release_lock_if_owner(info, lock_path)
791
824
  raise
792
825
 
793
826
  return info
@@ -810,7 +843,7 @@ def _run_stdio_gateway(*, advertise_mdns: bool = True) -> None:
810
843
  except KeyboardInterrupt:
811
844
  pass
812
845
  finally:
813
- release_lock_if_owner(info)
846
+ release_lock_if_owner(info, _ws_port_lock_path())
814
847
 
815
848
 
816
849
  def _resolve_mcp_http_endpoint() -> tuple[str, int]:
@@ -920,7 +953,7 @@ def _run_streamable_http_placeholder(*, advertise_mdns: bool = True) -> None:
920
953
  pass
921
954
  finally:
922
955
  if info is not None:
923
- release_lock_if_owner(info)
956
+ release_lock_if_owner(info, _ws_port_lock_path())
924
957
 
925
958
 
926
959
  def main(argv: list[str] | None = None) -> None:
@@ -43,6 +43,8 @@ RESPONSE_TIMEOUT = 10.0
43
43
  ToolCall = tuple[str, dict[str, Any]]
44
44
  ToolCallResult = tuple[Any, dict[str, Any] | None]
45
45
 
46
+ _SET_AVATAR_TOOL = "self.display.set_avatar"
47
+
46
48
  _TOOL_LANES = {
47
49
  "self.robot.": "servo",
48
50
  "self.wifi.": "wifi",
@@ -82,6 +84,8 @@ class ESP32Connection:
82
84
  self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
83
85
  self._connected = True
84
86
  self._initialized = False
87
+ self._tools_discovered = False
88
+ self._avatar_render_sent = False
85
89
  # Phase 4.5 avatar: pending load_avatar_set calls waiting for the
86
90
  # device's `avatar_set_loaded` reply. Keyed by expected checksum
87
91
  # so that overlapping fetches (different sets) can be discriminated.
@@ -102,6 +106,14 @@ class ESP32Connection:
102
106
  def initialized(self) -> bool:
103
107
  return self._initialized
104
108
 
109
+ @property
110
+ def tools_discovered(self) -> bool:
111
+ return self._tools_discovered
112
+
113
+ @property
114
+ def avatar_render_sent(self) -> bool:
115
+ return self._avatar_render_sent
116
+
105
117
  def _next_id(self) -> int:
106
118
  self._request_id += 1
107
119
  return self._request_id
@@ -162,6 +174,8 @@ class ESP32Connection:
162
174
  """Discover tools available on ESP32."""
163
175
  all_tools: list[dict[str, Any]] = []
164
176
  cursor = ""
177
+ self._tools_discovered = False
178
+ discovered = False
165
179
 
166
180
  while True:
167
181
  params: dict[str, Any] = {"cursor": cursor}
@@ -169,6 +183,7 @@ class ESP32Connection:
169
183
 
170
184
  if error:
171
185
  logger.error("tools/list failed: %s", error)
186
+ self.tools = all_tools
172
187
  break
173
188
 
174
189
  tools = result.get("tools", [])
@@ -176,10 +191,12 @@ class ESP32Connection:
176
191
 
177
192
  next_cursor = result.get("nextCursor", "")
178
193
  if not next_cursor:
194
+ discovered = True
179
195
  break
180
196
  cursor = next_cursor
181
197
 
182
198
  self.tools = all_tools
199
+ self._tools_discovered = discovered
183
200
  logger.info("Discovered %d tools on ESP32", len(all_tools))
184
201
  return all_tools
185
202
 
@@ -187,6 +204,8 @@ class ESP32Connection:
187
204
  self, name: str, arguments: dict[str, Any]
188
205
  ) -> tuple[Any, dict[str, Any] | None]:
189
206
  """Call a tool on ESP32."""
207
+ if name == _SET_AVATAR_TOOL:
208
+ self._avatar_render_sent = True
190
209
  return await self.send_mcp_request(
191
210
  "tools/call", {"name": name, "arguments": arguments}
192
211
  )
@@ -233,6 +252,9 @@ class ESP32Connection:
233
252
  return {"ok": False, "checksum": checksum, "error": "device_timeout"}
234
253
  except asyncio.CancelledError:
235
254
  return {"ok": False, "checksum": checksum, "error": "superseded"}
255
+ except ConnectionError:
256
+ self._avatar_set_waiters.pop(checksum, None)
257
+ return {"ok": False, "checksum": checksum, "error": "disconnected"}
236
258
  except Exception as exc:
237
259
  self._avatar_set_waiters.pop(checksum, None)
238
260
  return {"ok": False, "checksum": checksum, "error": f"send_failed: {exc}"}
@@ -353,6 +375,10 @@ class ESP32Connection:
353
375
  if not future.done():
354
376
  future.set_exception(ConnectionError("ESP32 disconnected"))
355
377
  self._pending.clear()
378
+ for future in self._avatar_set_waiters.values():
379
+ if not future.done():
380
+ future.set_exception(ConnectionError("ESP32 disconnected"))
381
+ self._avatar_set_waiters.clear()
356
382
 
357
383
 
358
384
  class ESP32Manager:
@@ -732,6 +758,10 @@ class ESP32Manager:
732
758
  vision_token=self._vision_token,
733
759
  ):
734
760
  await connection.discover_tools()
761
+ if not connection.tools_discovered:
762
+ logger.error("ESP32 tools discovery failed")
763
+ return
764
+ await self._auto_render_idle_avatar(connection, device_id)
735
765
  logger.info(
736
766
  "ESP32 ready: device=%s tools=%d",
737
767
  device_id,
@@ -740,6 +770,37 @@ class ESP32Manager:
740
770
  else:
741
771
  logger.error("ESP32 MCP initialization failed")
742
772
 
773
+ async def _auto_render_idle_avatar(
774
+ self, connection: ESP32Connection, device_id: str
775
+ ) -> None:
776
+ """Best-effort idle avatar render after a fresh device session init."""
777
+ if connection.avatar_render_sent:
778
+ return
779
+
780
+ logger.info(
781
+ "auto-rendering idle avatar (no explicit set_avatar yet): device=%s",
782
+ device_id,
783
+ )
784
+ try:
785
+ _result, error = await connection.call_tool(
786
+ _SET_AVATAR_TOOL,
787
+ {"face": "idle"},
788
+ )
789
+ except Exception as exc:
790
+ logger.warning(
791
+ "auto-rendering idle avatar failed: device=%s error=%s",
792
+ device_id,
793
+ exc,
794
+ )
795
+ return
796
+
797
+ if error:
798
+ logger.warning(
799
+ "auto-rendering idle avatar failed: device=%s error=%s",
800
+ device_id,
801
+ error,
802
+ )
803
+
743
804
  async def _emit_stackchan_event(self, payload: dict[str, Any]) -> None:
744
805
  """Forward a firmware-originated stackchan event to the MCP client."""
745
806
  event_type = payload.get("event_type")
@@ -199,3 +199,72 @@ async def test_pcm_no_auth_allows_unauthed_request_when_token_blank():
199
199
  # We don't auth-reject (would be 401); the next guard (sample-rate)
200
200
  # fires first, so 400 here means auth was skipped as intended.
201
201
  assert response.status == 400
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # /capture endpoint — multipart body-cap regression
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_capture_accepts_nonempty_question_field(tmp_path, monkeypatch):
211
+ """A non-empty multipart ``question`` field must not be rejected with 413.
212
+
213
+ Regression guard. The capture app raises aiohttp's per-request body cap so
214
+ /pcm can stream long PCM. It must NOT do so by setting ``client_max_size=0``:
215
+ aiohttp's multipart reader (>= 3.14) checks ``len(data) > client_max_size``
216
+ directly and, unlike ``request.read()`` / ``request.post()``, does not treat
217
+ ``0`` as "unlimited". With ``client_max_size=0`` every /capture upload that
218
+ carries a non-empty ``question`` form field (read via ``part.read()``) fails
219
+ with HTTP 413, surfacing on the device as "Failed to upload photo". A large
220
+ finite cap keeps /pcm unbounded while letting normal uploads through.
221
+ """
222
+ import aiohttp
223
+ from aiohttp.test_utils import TestClient, TestServer
224
+
225
+ from stackchan_mcp import capture_server
226
+
227
+ monkeypatch.setattr(capture_server, "CAPTURE_DIR", str(tmp_path))
228
+ app = create_capture_app(capture_token="") # blank token → auth skipped
229
+
230
+ async with TestClient(TestServer(app)) as client:
231
+ form = aiohttp.FormData()
232
+ form.add_field("question", "what is in front of you?")
233
+ form.add_field(
234
+ "file",
235
+ b"\xff\xd8\xff\xe0JFIF-dummy-jpeg-bytes",
236
+ filename="photo.jpg",
237
+ content_type="image/jpeg",
238
+ )
239
+ resp = await client.post("/capture", data=form)
240
+ assert resp.status == 200, await resp.text()
241
+
242
+
243
+ @pytest.mark.asyncio
244
+ async def test_capture_tolerates_non_utf8_question(tmp_path, monkeypatch):
245
+ """A malformed (non-UTF-8) question field must not 500 the upload.
246
+
247
+ The question is advisory metadata; a client that sends it in the wrong
248
+ encoding should still get its photo stored (decoded with errors=replace)
249
+ rather than a 500 that looks to the device like a failed capture.
250
+ """
251
+ import aiohttp
252
+ from aiohttp.test_utils import TestClient, TestServer
253
+
254
+ from stackchan_mcp import capture_server
255
+
256
+ monkeypatch.setattr(capture_server, "CAPTURE_DIR", str(tmp_path))
257
+ app = create_capture_app(capture_token="")
258
+
259
+ async with TestClient(TestServer(app)) as client:
260
+ with aiohttp.MultipartWriter("form-data") as mpwriter:
261
+ # Raw non-UTF-8 bytes (0x96 is a Shift-JIS lead byte, an invalid
262
+ # UTF-8 start byte) in the question part.
263
+ part = mpwriter.append(b"\x96\x96\x96")
264
+ part.set_content_disposition("form-data", name="question")
265
+ fpart = mpwriter.append(b"\xff\xd8\xff\xe0JFIF-dummy")
266
+ fpart.set_content_disposition(
267
+ "form-data", name="file", filename="photo.jpg"
268
+ )
269
+ resp = await client.post("/capture", data=mpwriter)
270
+ assert resp.status == 200, await resp.text()
@@ -189,7 +189,7 @@ def test_main_default_advertises_mdns(monkeypatch: pytest.MonkeyPatch) -> None:
189
189
  monkeypatch.setattr(cli, "_load_dotenv", lambda: None)
190
190
  monkeypatch.setattr(cli, "_ensure_libopus_findable", lambda: None)
191
191
  monkeypatch.setattr(cli, "_run", fake_run)
192
- monkeypatch.setattr(ownership, "release_lock_if_owner", lambda info: True)
192
+ monkeypatch.setattr(ownership, "release_lock_if_owner", lambda info, path=None: True)
193
193
 
194
194
  main([])
195
195
 
@@ -210,7 +210,7 @@ def test_main_no_mdns_disables_advertisement(
210
210
  monkeypatch.setattr(cli, "_load_dotenv", lambda: None)
211
211
  monkeypatch.setattr(cli, "_ensure_libopus_findable", lambda: None)
212
212
  monkeypatch.setattr(cli, "_run", fake_run)
213
- monkeypatch.setattr(ownership, "release_lock_if_owner", lambda info: True)
213
+ monkeypatch.setattr(ownership, "release_lock_if_owner", lambda info, path=None: True)
214
214
 
215
215
  main(["--no-mdns"])
216
216
 
@@ -325,7 +325,11 @@ def test_main_check_flag_remains_side_effect_free_with_no_mdns(
325
325
  async def fail_run(*, advertise_mdns: bool = True) -> None:
326
326
  raise AssertionError("--check must not start the gateway")
327
327
 
328
- monkeypatch.setattr(cli, "_run_preflight", lambda: 0)
328
+ # ``--check`` runs ``_run_ownership_check()``, which calls ``_load_dotenv()``.
329
+ # Without stubbing it, the developer's real ``gateway/.env`` is loaded into
330
+ # ``os.environ`` outside monkeypatch's tracking and never restored, polluting
331
+ # later tests (e.g. a ``STACKCHAN_TOKEN`` there breaks the WS-server suite).
332
+ monkeypatch.setattr(cli, "_load_dotenv", lambda: None)
329
333
  monkeypatch.setattr(cli, "_run", fail_run)
330
334
 
331
335
  with pytest.raises(SystemExit) as exc:
@@ -385,7 +389,9 @@ def test_streamable_http_releases_lock_after_daemon_exit(
385
389
  monkeypatch.setattr(cli, "_configure_gateway_startup", lambda: None)
386
390
  monkeypatch.setattr(cli, "_acquire_startup_lock", fake_acquire)
387
391
  monkeypatch.setattr(cli, "_run_streamable_http_daemon", fake_daemon)
388
- monkeypatch.setattr(ownership, "release_lock_if_owner", released.append)
392
+ monkeypatch.setattr(
393
+ ownership, "release_lock_if_owner", lambda info, path=None: released.append(info)
394
+ )
389
395
  monkeypatch.delenv("MCP_HTTP_HOST", raising=False)
390
396
  monkeypatch.delenv("MCP_HTTP_PORT", raising=False)
391
397
  monkeypatch.delenv("STACKCHAN_TOKEN", raising=False)
@@ -752,7 +758,7 @@ def test_main_check_flag_runs_ownership_check_and_exits(
752
758
 
753
759
  _isolate_preflight_env(monkeypatch, tmp_path)
754
760
  monkeypatch.setattr(cli, "_run_preflight", lambda: 99)
755
- monkeypatch.setattr(ownership, "read_lock", lambda: None)
761
+ monkeypatch.setattr(ownership, "read_lock", lambda path=None: None)
756
762
 
757
763
  with pytest.raises(SystemExit) as exc:
758
764
  main(["--check"])
@@ -762,6 +768,67 @@ def test_main_check_flag_runs_ownership_check_and_exits(
762
768
  assert "Result: ready" in out
763
769
 
764
770
 
771
+ def test_main_check_flag_inspects_per_ws_port_lock(
772
+ monkeypatch: pytest.MonkeyPatch,
773
+ capsys: pytest.CaptureFixture[str],
774
+ tmp_path: Path,
775
+ ) -> None:
776
+ """``--check`` inspects the lock for the configured WS_PORT.
777
+
778
+ Guards against the ``--check`` preflight reading the default
779
+ ``owner-8765.lock`` while a gateway configured for another port owns
780
+ ``owner-<port>.lock``. ``_run_ownership_check`` loads ``.env`` before
781
+ deriving the lock path, mirroring the startup order, so a WS_PORT set in
782
+ the environment (or ``.env``) is reflected here.
783
+ """
784
+ from stackchan_mcp import ownership
785
+
786
+ _isolate_preflight_env(monkeypatch, tmp_path)
787
+ monkeypatch.setenv("WS_PORT", "18765")
788
+ seen: list[Path] = []
789
+
790
+ def fake_read_lock(path: Path | None = None) -> None:
791
+ seen.append(path)
792
+ return None
793
+
794
+ monkeypatch.setattr(ownership, "read_lock", fake_read_lock)
795
+
796
+ with pytest.raises(SystemExit) as exc:
797
+ main(["--check"])
798
+ assert exc.value.code == 0
799
+ assert seen and seen[0] is not None and seen[0].name == "owner-18765.lock"
800
+ assert "owner-18765.lock" in capsys.readouterr().out
801
+
802
+
803
+ def test_main_check_flag_scopes_lock_for_ephemeral_ws_port(
804
+ monkeypatch: pytest.MonkeyPatch,
805
+ capsys: pytest.CaptureFixture[str],
806
+ tmp_path: Path,
807
+ ) -> None:
808
+ """``WS_PORT=0`` scopes the lock to ``owner-0.lock``, not the legacy path.
809
+
810
+ Preflight treats ``0`` as a valid (OS-assigned ephemeral) port, so the
811
+ ownership lock must stay per-port for it too. A truthiness check would send
812
+ ``0`` to the machine-global ``owner.lock`` and diverge from startup.
813
+ """
814
+ from stackchan_mcp import ownership
815
+
816
+ _isolate_preflight_env(monkeypatch, tmp_path)
817
+ monkeypatch.setenv("WS_PORT", "0")
818
+ seen: list[Path] = []
819
+
820
+ def fake_read_lock(path: Path | None = None) -> None:
821
+ seen.append(path)
822
+ return None
823
+
824
+ monkeypatch.setattr(ownership, "read_lock", fake_read_lock)
825
+
826
+ with pytest.raises(SystemExit) as exc:
827
+ main(["--check"])
828
+ assert exc.value.code == 0
829
+ assert seen and seen[0] is not None and seen[0].name == "owner-0.lock"
830
+
831
+
765
832
  def test_main_preflight_flag_runs_preflight_and_exits(
766
833
  monkeypatch: pytest.MonkeyPatch,
767
834
  ) -> None:
@@ -3,6 +3,7 @@
3
3
  import asyncio
4
4
  import gc
5
5
  import json
6
+ import logging
6
7
 
7
8
  import pytest
8
9
  import pytest_asyncio
@@ -132,6 +133,13 @@ async def test_esp32_hello_handshake(manager):
132
133
  }
133
134
  await ws.send(json.dumps(tools_resp))
134
135
 
136
+ auto_msg = await _expect_auto_idle_avatar(ws)
137
+ await _send_mcp_response(
138
+ ws,
139
+ auto_msg,
140
+ result={"content": [{"type": "text", "text": "true"}], "isError": False},
141
+ )
142
+
135
143
  # Wait for manager to process
136
144
  await asyncio.sleep(0.2)
137
145
 
@@ -318,6 +326,265 @@ async def test_connection_removes_pending_request_when_call_is_cancelled():
318
326
  assert conn._pending == {}
319
327
 
320
328
 
329
+ # ---------------------------------------------------------------------------
330
+ # Auto idle avatar render after session initialization (Issue #77)
331
+ # ---------------------------------------------------------------------------
332
+
333
+
334
+ class _InitDeviceConnection:
335
+ """Fake connection for exercising ESP32Manager._init_device."""
336
+
337
+ def __init__(
338
+ self,
339
+ *,
340
+ avatar_render_sent: bool = False,
341
+ discover_ok: bool = True,
342
+ auto_error: dict | None = None,
343
+ auto_exception: Exception | None = None,
344
+ ) -> None:
345
+ self.tools: list[dict] = []
346
+ self.tools_discovered = False
347
+ self.avatar_render_sent = avatar_render_sent
348
+ self.discover_ok = discover_ok
349
+ self.auto_error = auto_error
350
+ self.auto_exception = auto_exception
351
+ self.initialize_calls = 0
352
+ self.discover_calls = 0
353
+ self.call_tool_calls: list[tuple[str, dict]] = []
354
+
355
+ async def initialize(self, *, vision_url: str = "", vision_token: str = "") -> bool:
356
+ self.initialize_calls += 1
357
+ return True
358
+
359
+ async def discover_tools(self) -> list[dict]:
360
+ self.discover_calls += 1
361
+ if not self.discover_ok:
362
+ self.tools = []
363
+ self.tools_discovered = False
364
+ return self.tools
365
+
366
+ self.tools = [
367
+ {
368
+ "name": "self.display.set_avatar",
369
+ "description": "Set avatar",
370
+ "inputSchema": {"type": "object"},
371
+ }
372
+ ]
373
+ self.tools_discovered = True
374
+ return self.tools
375
+
376
+ async def call_tool(self, name: str, arguments: dict):
377
+ self.call_tool_calls.append((name, arguments))
378
+ if name == "self.display.set_avatar":
379
+ self.avatar_render_sent = True
380
+ if self.auto_exception is not None:
381
+ raise self.auto_exception
382
+ return {"content": [{"type": "text", "text": "true"}]}, self.auto_error
383
+
384
+
385
+ class _AutoMcpWebSocket:
386
+ """Fake WebSocket that responds to gateway MCP requests immediately."""
387
+
388
+ def __init__(self) -> None:
389
+ self.connection: ESP32Connection | None = None
390
+ self.sent: list[str] = []
391
+ self.tool_calls: list[tuple[str, dict]] = []
392
+
393
+ async def send(self, data: str) -> None:
394
+ self.sent.append(data)
395
+ message = json.loads(data)
396
+ payload = message["payload"]
397
+ method = payload["method"]
398
+
399
+ if method == "initialize":
400
+ result = {
401
+ "protocolVersion": "2024-11-05",
402
+ "capabilities": {"tools": {}},
403
+ "serverInfo": {"name": "test-device", "version": "1.0.0"},
404
+ }
405
+ elif method == "tools/list":
406
+ result = {
407
+ "tools": [
408
+ {
409
+ "name": "self.display.set_avatar",
410
+ "description": "Set avatar",
411
+ "inputSchema": {"type": "object"},
412
+ }
413
+ ],
414
+ "nextCursor": "",
415
+ }
416
+ elif method == "tools/call":
417
+ params = payload["params"]
418
+ self.tool_calls.append((params["name"], params["arguments"]))
419
+ result = {"content": [{"type": "text", "text": "true"}], "isError": False}
420
+ else:
421
+ raise AssertionError(f"unexpected MCP method: {method}")
422
+
423
+ assert self.connection is not None
424
+ self.connection.handle_response(
425
+ {"jsonrpc": "2.0", "id": payload["id"], "result": result}
426
+ )
427
+
428
+
429
+ @pytest.mark.asyncio
430
+ async def test_init_auto_renders_idle_avatar_after_tools_list():
431
+ """A successful initialize + tools/list sends idle set_avatar once."""
432
+ ws = _AutoMcpWebSocket()
433
+ connection = ESP32Connection(ws, session_id="session-auto") # type: ignore[arg-type]
434
+ ws.connection = connection
435
+ mgr = ESP32Manager()
436
+
437
+ await mgr._init_device(connection, "device-test")
438
+
439
+ assert ws.tool_calls == [("self.display.set_avatar", {"face": "idle"})]
440
+ assert connection.avatar_render_sent is True
441
+
442
+
443
+ @pytest.mark.asyncio
444
+ async def test_init_skips_auto_idle_avatar_when_avatar_already_sent():
445
+ """The connection-scoped flag suppresses the automatic idle render."""
446
+ mgr = ESP32Manager()
447
+ connection = _InitDeviceConnection(avatar_render_sent=True)
448
+
449
+ await mgr._init_device(connection, "device-test") # type: ignore[arg-type]
450
+
451
+ assert connection.initialize_calls == 1
452
+ assert connection.discover_calls == 1
453
+ assert connection.call_tool_calls == []
454
+
455
+
456
+ @pytest.mark.asyncio
457
+ async def test_init_skips_auto_idle_avatar_when_tools_discovery_fails(caplog):
458
+ """The auto-render path only runs after successful tools/list discovery."""
459
+ caplog.set_level(logging.INFO, logger="stackchan_mcp.esp32_client")
460
+ mgr = ESP32Manager()
461
+ connection = _InitDeviceConnection(discover_ok=False)
462
+
463
+ await mgr._init_device(connection, "device-test") # type: ignore[arg-type]
464
+
465
+ assert connection.initialize_calls == 1
466
+ assert connection.discover_calls == 1
467
+ assert connection.call_tool_calls == []
468
+ assert "ESP32 ready: device=device-test" not in caplog.text
469
+
470
+
471
+ @pytest.mark.parametrize("failure_mode", ["error", "timeout"])
472
+ @pytest.mark.asyncio
473
+ async def test_init_continues_when_auto_idle_avatar_fails(failure_mode, caplog):
474
+ """Auto-render failures are warnings and do not block ESP32 ready."""
475
+ caplog.set_level(logging.INFO, logger="stackchan_mcp.esp32_client")
476
+ if failure_mode == "error":
477
+ connection = _InitDeviceConnection(
478
+ auto_error={"code": -32000, "message": "device rejected set_avatar"}
479
+ )
480
+ else:
481
+ connection = _InitDeviceConnection(
482
+ auto_exception=asyncio.TimeoutError("set_avatar timed out")
483
+ )
484
+ mgr = ESP32Manager()
485
+
486
+ await mgr._init_device(connection, "device-test") # type: ignore[arg-type]
487
+
488
+ assert connection.call_tool_calls == [
489
+ ("self.display.set_avatar", {"face": "idle"})
490
+ ]
491
+ assert "auto-rendering idle avatar failed" in caplog.text
492
+ assert "ESP32 ready: device=device-test tools=1" in caplog.text
493
+
494
+
495
+ @pytest.mark.asyncio
496
+ async def test_reconnect_auto_renders_idle_avatar_again():
497
+ """A new ESP32Connection gets a fresh auto-render flag."""
498
+ first_ws = _AutoMcpWebSocket()
499
+ first = ESP32Connection(first_ws, session_id="session-first") # type: ignore[arg-type]
500
+ first_ws.connection = first
501
+ second_ws = _AutoMcpWebSocket()
502
+ second = ESP32Connection(second_ws, session_id="session-second") # type: ignore[arg-type]
503
+ second_ws.connection = second
504
+ mgr = ESP32Manager()
505
+
506
+ await mgr._init_device(first, "device-test")
507
+ await mgr._init_device(second, "device-test")
508
+
509
+ assert first_ws.tool_calls == [("self.display.set_avatar", {"face": "idle"})]
510
+ assert second_ws.tool_calls == [("self.display.set_avatar", {"face": "idle"})]
511
+
512
+
513
+ @pytest.mark.asyncio
514
+ async def test_send_avatar_set_fetch_resolves_when_loaded_event_arrives():
515
+ """avatar_set_loaded resolves the matching load_avatar_set waiter."""
516
+ ws = _FakeWebSocket()
517
+ conn = ESP32Connection(ws, session_id="session-avatar") # type: ignore[arg-type]
518
+
519
+ task = asyncio.create_task(
520
+ conn.send_avatar_set_fetch(
521
+ url="https://example.invalid/avatar-set.bin",
522
+ token="test-token",
523
+ mode="replace",
524
+ checksum="sha256:avatar-set",
525
+ expected_size=1234,
526
+ timeout=30.0,
527
+ )
528
+ )
529
+
530
+ await asyncio.sleep(0)
531
+ assert len(ws.sent) == 1
532
+ assert json.loads(ws.sent[0]) == {
533
+ "type": "avatar_set_fetch",
534
+ "url": "https://example.invalid/avatar-set.bin",
535
+ "token": "test-token",
536
+ "mode": "replace",
537
+ "checksum": "sha256:avatar-set",
538
+ "expected_size": 1234,
539
+ }
540
+
541
+ payload = {
542
+ "ok": True,
543
+ "checksum": "sha256:avatar-set",
544
+ "bytes": 1234,
545
+ }
546
+ conn.handle_avatar_set_loaded(payload)
547
+
548
+ result = await asyncio.wait_for(task, timeout=1.0)
549
+ assert result == payload
550
+ assert conn._avatar_set_waiters == {}
551
+
552
+
553
+ @pytest.mark.asyncio
554
+ async def test_send_avatar_set_fetch_returns_disconnected_when_connection_drops():
555
+ """Disconnect wakes an in-flight avatar set fetch without waiting for timeout."""
556
+ ws = _FakeWebSocket()
557
+ conn = ESP32Connection(ws, session_id="session-avatar") # type: ignore[arg-type]
558
+
559
+ task = asyncio.create_task(
560
+ conn.send_avatar_set_fetch(
561
+ url="https://example.invalid/avatar-set.bin",
562
+ token="test-token",
563
+ mode="replace",
564
+ checksum="sha256:avatar-set",
565
+ expected_size=1234,
566
+ timeout=30.0,
567
+ )
568
+ )
569
+
570
+ await asyncio.sleep(0)
571
+ assert len(ws.sent) == 1
572
+ assert len(conn._avatar_set_waiters) == 1
573
+
574
+ started_at = asyncio.get_running_loop().time()
575
+ conn.disconnect()
576
+ result = await asyncio.wait_for(task, timeout=1.0)
577
+ elapsed = asyncio.get_running_loop().time() - started_at
578
+
579
+ assert result == {
580
+ "ok": False,
581
+ "checksum": "sha256:avatar-set",
582
+ "error": "disconnected",
583
+ }
584
+ assert elapsed < 1.0
585
+ assert conn._avatar_set_waiters == {}
586
+
587
+
321
588
  class _GateableConnection:
322
589
  """Fake initialized connection with per-tool release gates."""
323
590
 
@@ -726,7 +993,7 @@ def test_connection_default_protocol_version_is_one():
726
993
  # ---------------------------------------------------------------------------
727
994
 
728
995
 
729
- async def _complete_handshake(ws, tools=None):
996
+ async def _complete_handshake(ws, tools=None, *, consume_auto_avatar=True):
730
997
  """Complete the full ESP32 handshake sequence."""
731
998
  if tools is None:
732
999
  tools = []
@@ -774,6 +1041,49 @@ async def _complete_handshake(ws, tools=None):
774
1041
  },
775
1042
  }
776
1043
  await ws.send(json.dumps(tools_resp))
1044
+ if not consume_auto_avatar:
1045
+ return None
1046
+
1047
+ auto_msg = await _expect_auto_idle_avatar(ws)
1048
+ await _send_mcp_response(
1049
+ ws,
1050
+ auto_msg,
1051
+ result={"content": [{"type": "text", "text": "true"}], "isError": False},
1052
+ )
1053
+ return auto_msg
1054
+
1055
+
1056
+ async def _expect_auto_idle_avatar(ws):
1057
+ """Receive and assert the automatic idle avatar tools/call."""
1058
+ auto_raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
1059
+ auto_msg = json.loads(auto_raw)
1060
+ assert auto_msg["type"] == "mcp"
1061
+ assert auto_msg["payload"]["method"] == "tools/call"
1062
+ assert auto_msg["payload"]["params"]["name"] == "self.display.set_avatar"
1063
+ assert auto_msg["payload"]["params"]["arguments"] == {"face": "idle"}
1064
+ return auto_msg
1065
+
1066
+
1067
+ async def _send_mcp_response(ws, req_msg, *, result=None, error=None):
1068
+ """Send a JSON-RPC response for a gateway-originated MCP request."""
1069
+ payload = {
1070
+ "jsonrpc": "2.0",
1071
+ "id": req_msg["payload"]["id"],
1072
+ }
1073
+ if error is None:
1074
+ payload["result"] = result or {}
1075
+ else:
1076
+ payload["error"] = error
1077
+
1078
+ await ws.send(
1079
+ json.dumps(
1080
+ {
1081
+ "session_id": req_msg["session_id"],
1082
+ "type": "mcp",
1083
+ "payload": payload,
1084
+ }
1085
+ )
1086
+ )
777
1087
 
778
1088
 
779
1089
  # --- Device-driven listen capture --------------------------------------------
@@ -932,4 +1242,3 @@ async def test_device_driven_listen_cleanup_on_disconnect(manager_with_hook):
932
1242
  assert not is_recording(), "recording slot was leaked across connections"
933
1243
  # No push should have fired for the aborted capture.
934
1244
  assert calls == []
935
-
@@ -2068,7 +2068,7 @@ wheels = [
2068
2068
 
2069
2069
  [[package]]
2070
2070
  name = "stackchan-mcp"
2071
- version = "0.13.0"
2071
+ version = "0.14.0"
2072
2072
  source = { editable = "." }
2073
2073
  dependencies = [
2074
2074
  { name = "aiohttp" },
File without changes
File without changes
File without changes