stackchan-mcp 0.7.0__tar.gz → 0.8.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 (51) hide show
  1. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/PKG-INFO +1 -1
  2. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/pyproject.toml +1 -1
  3. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/esp32_client.py +91 -5
  4. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stdio_server.py +79 -0
  5. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_audio_utils.py +2 -1
  6. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_esp32_client.py +264 -1
  7. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/uv.lock +1 -1
  8. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/.env.example +0 -0
  9. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/.gitignore +0 -0
  10. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/LICENSE +0 -0
  11. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/README.md +0 -0
  12. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/__init__.py +0 -0
  13. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/__main__.py +0 -0
  14. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/audio_stream.py +0 -0
  15. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/capture_server.py +0 -0
  16. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/cli.py +0 -0
  17. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/gateway.py +0 -0
  18. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/__init__.py +0 -0
  19. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/audio.py +0 -0
  20. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/camera.py +0 -0
  21. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/robot.py +0 -0
  22. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/mcp_router.py +0 -0
  23. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/protocol.py +0 -0
  24. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/server.py +0 -0
  25. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/__init__.py +0 -0
  26. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  27. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/base.py +0 -0
  28. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  29. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  30. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/orchestrator.py +0 -0
  31. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tools.py +0 -0
  32. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/__init__.py +0 -0
  33. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  34. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/base.py +0 -0
  35. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/orchestrator.py +0 -0
  36. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/voicevox.py +0 -0
  37. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/_audio_fixtures.py +0 -0
  38. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/conftest.py +0 -0
  39. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_audio_stream.py +0 -0
  40. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_capture_server.py +0 -0
  41. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_cli.py +0 -0
  42. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_gateway.py +0 -0
  43. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_mcp_router.py +0 -0
  44. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_orchestrator.py +0 -0
  45. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_protocol.py +0 -0
  46. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_stdio_server.py +0 -0
  47. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_stt_audio_utils.py +0 -0
  48. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_stt_framework.py +0 -0
  49. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_stt_orchestrator.py +0 -0
  50. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_tts_framework.py +0 -0
  51. {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.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.7.0
3
+ Version: 0.8.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.7.0"
3
+ version = "0.8.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"
@@ -7,6 +7,7 @@ and as an MCP client that sends commands TO the ESP32.
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
10
+ from collections.abc import Sequence
10
11
  import json
11
12
  import logging
12
13
  import os
@@ -25,6 +26,34 @@ logger = logging.getLogger(__name__)
25
26
  # Timeout for waiting for ESP32 responses
26
27
  RESPONSE_TIMEOUT = 10.0
27
28
 
29
+ ToolCall = tuple[str, dict[str, Any]]
30
+ ToolCallResult = tuple[Any, dict[str, Any] | None]
31
+
32
+ _TOOL_LANES = {
33
+ "self.robot.": "servo",
34
+ "self.led.": "led",
35
+ "self.display.": "avatar",
36
+ "self.screen.": "display",
37
+ "self.audio_speaker.": "audio",
38
+ "self.camera.": "camera",
39
+ "self.touch.": "touch",
40
+ "self.get_device_status": "status",
41
+ }
42
+
43
+
44
+ def _hardware_lane(tool_name: str) -> str:
45
+ """Return the hardware lane used for per-peripheral dispatch ordering."""
46
+ for prefix, lane in _TOOL_LANES.items():
47
+ if tool_name.startswith(prefix):
48
+ return lane
49
+ return "default"
50
+
51
+
52
+ def _retrieve_future_exception(future: asyncio.Future[Any]) -> None:
53
+ """Mark a completed Future exception as observed, if it has one."""
54
+ if future.done() and not future.cancelled():
55
+ future.exception()
56
+
28
57
 
29
58
  class ESP32Connection:
30
59
  """Manages a single ESP32 device connection."""
@@ -75,14 +104,18 @@ class ESP32Connection:
75
104
  self._pending[req_id] = future
76
105
 
77
106
  try:
78
- await self._ws.send(json.dumps(message))
107
+ await self._ws_send(json.dumps(message))
79
108
  response = await asyncio.wait_for(future, timeout=RESPONSE_TIMEOUT)
80
109
  return parse_jsonrpc_response(response)
110
+ except asyncio.CancelledError:
111
+ self._pending.pop(req_id, None)
112
+ raise
81
113
  except asyncio.TimeoutError:
82
114
  self._pending.pop(req_id, None)
83
115
  return None, {"code": -32000, "message": f"Timeout waiting for ESP32 response (method={method})"}
84
116
  except Exception as exc:
85
117
  self._pending.pop(req_id, None)
118
+ _retrieve_future_exception(future)
86
119
  return None, {"code": -32000, "message": f"ESP32 communication error: {exc}"}
87
120
 
88
121
  async def initialize(self, vision_url: str = "", vision_token: str = "") -> bool:
@@ -285,6 +318,17 @@ class ESP32Manager:
285
318
  # observable from gateway code; if a full-duplex contract
286
319
  # ever lands later the lock can split again.
287
320
  self._listen_lock = self._tts_lock
321
+ self._tool_lane_locks = {
322
+ "servo": asyncio.Lock(),
323
+ "led": asyncio.Lock(),
324
+ "avatar": asyncio.Lock(),
325
+ "display": asyncio.Lock(),
326
+ "audio": asyncio.Lock(),
327
+ "camera": asyncio.Lock(),
328
+ "touch": asyncio.Lock(),
329
+ "status": asyncio.Lock(),
330
+ "default": asyncio.Lock(),
331
+ }
288
332
 
289
333
  @property
290
334
  def device_connected(self) -> bool:
@@ -481,13 +525,55 @@ class ESP32Manager:
481
525
 
482
526
  async def call_tool(
483
527
  self, name: str, arguments: dict[str, Any]
484
- ) -> tuple[Any, dict[str, Any] | None]:
528
+ ) -> ToolCallResult:
485
529
  """Call a tool on the connected ESP32 device."""
530
+ result = await self.call_tools([(name, arguments)])
531
+ return result[0]
532
+
533
+ async def call_tools(self, calls: Sequence[ToolCall]) -> list[ToolCallResult]:
534
+ """Call multiple ESP32 tools while preserving per-hardware ordering.
535
+
536
+ Existing single-tool callers should continue to use ``call_tool``.
537
+ This helper is for compound gateway flows that can safely overlap
538
+ hardware-independent peripherals, such as servo + LEDs + avatar.
539
+ Calls sharing the same hardware lane are serialized; calls on
540
+ different lanes are dispatched concurrently.
541
+ """
542
+ if not calls:
543
+ return []
486
544
  if not self._connection or not self._connection.connected:
487
- return None, {"code": -32000, "message": "No ESP32 device connected"}
545
+ return [
546
+ (None, {"code": -32000, "message": "No ESP32 device connected"})
547
+ for _ in calls
548
+ ]
488
549
  if not self._connection.initialized:
489
- return None, {"code": -32000, "message": "ESP32 not initialized"}
490
- return await self._connection.call_tool(name, arguments)
550
+ return [
551
+ (None, {"code": -32000, "message": "ESP32 not initialized"})
552
+ for _ in calls
553
+ ]
554
+
555
+ connection = self._connection
556
+ return list(
557
+ await asyncio.gather(
558
+ *(
559
+ self._call_tool_on_connection(connection, name, arguments)
560
+ for name, arguments in calls
561
+ )
562
+ )
563
+ )
564
+
565
+ async def _call_tool_on_connection(
566
+ self,
567
+ connection: ESP32Connection,
568
+ name: str,
569
+ arguments: dict[str, Any],
570
+ ) -> ToolCallResult:
571
+ lane = _hardware_lane(name)
572
+ lock = self._tool_lane_locks[lane]
573
+ async with lock:
574
+ if connection is not self._connection or not connection.connected:
575
+ return None, {"code": -32000, "message": "ESP32 not connected"}
576
+ return await connection.call_tool(name, arguments)
491
577
 
492
578
  async def send_audio_frame(self, opus_frame: bytes) -> None:
493
579
  """Push a single Opus frame to the connected device.
@@ -296,6 +296,77 @@ def create_server() -> Server:
296
296
  "required": ["enabled"],
297
297
  },
298
298
  ),
299
+ Tool(
300
+ name="set_servo_torque",
301
+ description=(
302
+ "Enable or disable SCS0009 servo torque on the yaw / "
303
+ "pitch axes independently. Disabling torque stops motor "
304
+ "current on that axis; the head holds via static "
305
+ "friction (no motion is commanded). On disable, the "
306
+ "firmware also cancels any in-flight MotionDriver "
307
+ "interpolation and marks the axis position unknown so "
308
+ "a subsequent same-target set_head_angles is re-"
309
+ "dispatched rather than no-op-optimized. Re-enabling "
310
+ "torque does NOT trigger a move; the next "
311
+ "set_head_angles or wobble call will. Diagnostic / "
312
+ "power-management primitive used to observe physical "
313
+ "head behavior under torque-off (Issue #163; auto "
314
+ "release on idle is Issue #152 Phase 4)."
315
+ ),
316
+ inputSchema={
317
+ "type": "object",
318
+ "properties": {
319
+ "yaw_enabled": {
320
+ "type": "boolean",
321
+ "description": (
322
+ "True to enable yaw axis torque, false to "
323
+ "disable."
324
+ ),
325
+ },
326
+ "pitch_enabled": {
327
+ "type": "boolean",
328
+ "description": (
329
+ "True to enable pitch axis torque, false "
330
+ "to disable."
331
+ ),
332
+ },
333
+ },
334
+ "required": ["yaw_enabled", "pitch_enabled"],
335
+ },
336
+ ),
337
+ Tool(
338
+ name="set_auto_torque_release",
339
+ description=(
340
+ "Enable or disable firmware-side automatic SCS0009 "
341
+ "torque release after motion idle timeout. timeout_ms "
342
+ "is clamped by the firmware to 500..600000 ms. "
343
+ "Disabling this setting does not re-enable torque if "
344
+ "it is already released; the next set_head_angles, "
345
+ "wobble, or explicit set_servo_torque(true, true) call "
346
+ "re-engages torque."
347
+ ),
348
+ inputSchema={
349
+ "type": "object",
350
+ "properties": {
351
+ "enabled": {
352
+ "type": "boolean",
353
+ "description": (
354
+ "True to enable idle auto-release, false "
355
+ "to disable it."
356
+ ),
357
+ },
358
+ "timeout_ms": {
359
+ "type": "integer",
360
+ "description": (
361
+ "Idle timeout in milliseconds. Values "
362
+ "outside 500..600000 are clamped by the "
363
+ "firmware handler."
364
+ ),
365
+ },
366
+ },
367
+ "required": ["enabled", "timeout_ms"],
368
+ },
369
+ ),
299
370
  Tool(
300
371
  name="get_touch_state",
301
372
  description=(
@@ -677,6 +748,14 @@ def create_server() -> Server:
677
748
  "self.display.set_blink",
678
749
  arguments,
679
750
  ),
751
+ "set_servo_torque": (
752
+ "self.robot.set_servo_torque",
753
+ arguments,
754
+ ),
755
+ "set_auto_torque_release": (
756
+ "self.robot.set_auto_torque_release",
757
+ arguments,
758
+ ),
680
759
  "get_touch_state": (
681
760
  "self.touch.get_touch_state",
682
761
  {},
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import array
6
+ import importlib
6
7
 
7
8
  import pytest
8
9
 
@@ -206,8 +207,8 @@ def test_chunk_pcm_into_frames_rejects_non_positive_size():
206
207
 
207
208
  def test_encode_opus_frames_produces_frames_when_libopus_available():
208
209
  """When libopus is reachable, encode_opus_frames yields one frame per chunk."""
209
- opuslib = pytest.importorskip("opuslib")
210
210
  try:
211
+ opuslib = importlib.import_module("opuslib")
211
212
  opuslib.Encoder(16000, 1, opuslib.APPLICATION_VOIP)
212
213
  except Exception as exc: # libopus not loadable
213
214
  pytest.skip(f"libopus not available: {exc}")
@@ -1,13 +1,14 @@
1
1
  """Tests for ESP32 client connection management."""
2
2
 
3
3
  import asyncio
4
+ import gc
4
5
  import json
5
6
 
6
7
  import pytest
7
8
  import pytest_asyncio
8
9
  import websockets
9
10
 
10
- from stackchan_mcp.esp32_client import ESP32Connection, ESP32Manager
11
+ from stackchan_mcp.esp32_client import ESP32Connection, ESP32Manager, _hardware_lane
11
12
 
12
13
 
13
14
  @pytest_asyncio.fixture
@@ -223,6 +224,242 @@ async def test_auth_rejection(manager):
223
224
  del os.environ["STACKCHAN_TOKEN"]
224
225
 
225
226
 
227
+ # ---------------------------------------------------------------------------
228
+ # Parallel hardware-lane dispatch (Issue #73)
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ @pytest.mark.parametrize(
233
+ ("tool_name", "lane"),
234
+ [
235
+ ("self.robot.set_head_angles", "servo"),
236
+ ("self.led.set_many", "led"),
237
+ ("self.display.set_avatar", "avatar"),
238
+ ("self.screen.set_brightness", "display"),
239
+ ("self.audio_speaker.set_volume", "audio"),
240
+ ("self.camera.take_photo", "camera"),
241
+ ("self.touch.get_touch_state", "touch"),
242
+ ("self.get_device_status", "status"),
243
+ ("self.unknown.experimental", "default"),
244
+ ],
245
+ )
246
+ def test_hardware_lane_covers_gateway_tool_routes(tool_name, lane):
247
+ """Gateway-routed ESP32 tools map to explicit hardware lanes."""
248
+ assert _hardware_lane(tool_name) == lane
249
+
250
+
251
+ @pytest.mark.asyncio
252
+ async def test_connection_pipelines_concurrent_tool_calls_before_first_response():
253
+ """Concurrent tools/call requests are sent before either response arrives."""
254
+ ws = _FakeWebSocket()
255
+ conn = ESP32Connection(ws, session_id="session-parallel") # type: ignore[arg-type]
256
+
257
+ servo_task = asyncio.create_task(
258
+ conn.call_tool("self.robot.set_head_angles", {"yaw": 10, "pitch": 30})
259
+ )
260
+ led_task = asyncio.create_task(
261
+ conn.call_tool("self.led.set_many", {"colors": "[[255, 0, 0]]"})
262
+ )
263
+
264
+ await asyncio.sleep(0)
265
+
266
+ assert len(ws.sent) == 2
267
+ sent_messages = [json.loads(message) for message in ws.sent]
268
+ request_ids = [message["payload"]["id"] for message in sent_messages]
269
+ assert [message["payload"]["method"] for message in sent_messages] == [
270
+ "tools/call",
271
+ "tools/call",
272
+ ]
273
+ assert [message["payload"]["params"]["name"] for message in sent_messages] == [
274
+ "self.robot.set_head_angles",
275
+ "self.led.set_many",
276
+ ]
277
+
278
+ conn.handle_response(
279
+ {
280
+ "jsonrpc": "2.0",
281
+ "id": request_ids[1],
282
+ "result": {"content": [{"type": "text", "text": "led"}]},
283
+ }
284
+ )
285
+ conn.handle_response(
286
+ {
287
+ "jsonrpc": "2.0",
288
+ "id": request_ids[0],
289
+ "result": {"content": [{"type": "text", "text": "servo"}]},
290
+ }
291
+ )
292
+
293
+ servo_result, led_result = await asyncio.gather(servo_task, led_task)
294
+ assert servo_result[0]["content"][0]["text"] == "servo"
295
+ assert servo_result[1] is None
296
+ assert led_result[0]["content"][0]["text"] == "led"
297
+ assert led_result[1] is None
298
+
299
+
300
+ @pytest.mark.asyncio
301
+ async def test_connection_removes_pending_request_when_call_is_cancelled():
302
+ """Cancelling a tool call does not leave a stale pending response slot."""
303
+ ws = _FakeWebSocket()
304
+ conn = ESP32Connection(ws, session_id="session-cancel") # type: ignore[arg-type]
305
+
306
+ task = asyncio.create_task(
307
+ conn.call_tool("self.robot.set_head_angles", {"yaw": 10, "pitch": 30})
308
+ )
309
+
310
+ await asyncio.sleep(0)
311
+ assert len(ws.sent) == 1
312
+ assert len(conn._pending) == 1
313
+
314
+ task.cancel()
315
+ with pytest.raises(asyncio.CancelledError):
316
+ await task
317
+
318
+ assert conn._pending == {}
319
+
320
+
321
+ class _GateableConnection:
322
+ """Fake initialized connection with per-tool release gates."""
323
+
324
+ connected = True
325
+ initialized = True
326
+
327
+ def __init__(self, releases: dict[str, asyncio.Event]) -> None:
328
+ self.releases = releases
329
+ self.started: list[str] = []
330
+ self.finished: list[str] = []
331
+ self.all_started = asyncio.Event()
332
+
333
+ async def call_tool(self, name, arguments): # noqa: ARG002 - test fake
334
+ self.started.append(name)
335
+ if len(self.started) >= len(self.releases):
336
+ self.all_started.set()
337
+ await self.releases[name].wait()
338
+ self.finished.append(name)
339
+ return {"content": [{"type": "text", "text": name}]}, None
340
+
341
+
342
+ @pytest.mark.asyncio
343
+ async def test_manager_call_tools_dispatches_independent_lanes_in_parallel():
344
+ """Servo, LED, and avatar calls start together instead of waiting in line."""
345
+ releases = {
346
+ "self.robot.set_head_angles": asyncio.Event(),
347
+ "self.led.set_many": asyncio.Event(),
348
+ "self.display.set_avatar": asyncio.Event(),
349
+ }
350
+ connection = _GateableConnection(releases)
351
+ mgr = ESP32Manager()
352
+ mgr._connection = connection # type: ignore[assignment]
353
+
354
+ task = asyncio.create_task(
355
+ mgr.call_tools(
356
+ [
357
+ ("self.robot.set_head_angles", {"yaw": 0, "pitch": 45}),
358
+ ("self.led.set_many", {"colors": "[]"}),
359
+ ("self.display.set_avatar", {"face": "happy"}),
360
+ ]
361
+ )
362
+ )
363
+
364
+ await asyncio.wait_for(connection.all_started.wait(), timeout=1.0)
365
+ assert connection.started == [
366
+ "self.robot.set_head_angles",
367
+ "self.led.set_many",
368
+ "self.display.set_avatar",
369
+ ]
370
+ assert connection.finished == []
371
+
372
+ for release in releases.values():
373
+ release.set()
374
+ results = await asyncio.wait_for(task, timeout=1.0)
375
+
376
+ assert [result[0]["content"][0]["text"] for result in results] == [
377
+ "self.robot.set_head_angles",
378
+ "self.led.set_many",
379
+ "self.display.set_avatar",
380
+ ]
381
+ assert [error for _, error in results] == [None, None, None]
382
+
383
+
384
+ @pytest.mark.asyncio
385
+ async def test_manager_call_tool_uses_lane_dispatch_for_existing_api():
386
+ """Existing single-tool API can still overlap independent hardware lanes."""
387
+ releases = {
388
+ "self.robot.set_head_angles": asyncio.Event(),
389
+ "self.led.set_many": asyncio.Event(),
390
+ }
391
+ connection = _GateableConnection(releases)
392
+ mgr = ESP32Manager()
393
+ mgr._connection = connection # type: ignore[assignment]
394
+
395
+ servo_task = asyncio.create_task(
396
+ mgr.call_tool("self.robot.set_head_angles", {"yaw": 0, "pitch": 45})
397
+ )
398
+ led_task = asyncio.create_task(
399
+ mgr.call_tool("self.led.set_many", {"colors": "[]"})
400
+ )
401
+
402
+ await asyncio.wait_for(connection.all_started.wait(), timeout=1.0)
403
+ assert connection.started == [
404
+ "self.robot.set_head_angles",
405
+ "self.led.set_many",
406
+ ]
407
+ assert connection.finished == []
408
+
409
+ for release in releases.values():
410
+ release.set()
411
+ results = await asyncio.wait_for(
412
+ asyncio.gather(servo_task, led_task),
413
+ timeout=1.0,
414
+ )
415
+
416
+ assert [result[0]["content"][0]["text"] for result in results] == [
417
+ "self.robot.set_head_angles",
418
+ "self.led.set_many",
419
+ ]
420
+ assert [error for _, error in results] == [None, None]
421
+
422
+
423
+ @pytest.mark.asyncio
424
+ async def test_manager_call_tools_serializes_calls_on_same_hardware_lane():
425
+ """Two servo calls keep their relative order on the servo lane."""
426
+ releases = {
427
+ "self.robot.set_head_angles": asyncio.Event(),
428
+ "self.robot.get_head_angles": asyncio.Event(),
429
+ }
430
+ connection = _GateableConnection(releases)
431
+ mgr = ESP32Manager()
432
+ mgr._connection = connection # type: ignore[assignment]
433
+
434
+ task = asyncio.create_task(
435
+ mgr.call_tools(
436
+ [
437
+ ("self.robot.set_head_angles", {"yaw": 0, "pitch": 45}),
438
+ ("self.robot.get_head_angles", {}),
439
+ ]
440
+ )
441
+ )
442
+
443
+ await asyncio.sleep(0)
444
+ await asyncio.sleep(0)
445
+ assert connection.started == ["self.robot.set_head_angles"]
446
+
447
+ releases["self.robot.set_head_angles"].set()
448
+ await asyncio.sleep(0)
449
+ await asyncio.sleep(0)
450
+ assert connection.started == [
451
+ "self.robot.set_head_angles",
452
+ "self.robot.get_head_angles",
453
+ ]
454
+
455
+ releases["self.robot.get_head_angles"].set()
456
+ await asyncio.wait_for(task, timeout=1.0)
457
+ assert connection.finished == [
458
+ "self.robot.set_head_angles",
459
+ "self.robot.get_head_angles",
460
+ ]
461
+
462
+
226
463
  # ---------------------------------------------------------------------------
227
464
  # send_audio_frame (TTS pipeline egress, Issue #70 PR2)
228
465
  # ---------------------------------------------------------------------------
@@ -443,6 +680,32 @@ async def test_send_tts_state_translates_oserror_to_connection_error():
443
680
  assert not conn.connected
444
681
 
445
682
 
683
+ @pytest.mark.asyncio
684
+ async def test_send_mcp_request_translates_send_failure_and_marks_disconnected():
685
+ """tools/call send failures use the same connection-state handling as TTS."""
686
+ ws = _FailingWebSocket(OSError("broken pipe"))
687
+ conn = ESP32Connection(ws, session_id="session-1") # type: ignore[arg-type]
688
+ loop = asyncio.get_running_loop()
689
+ loop_errors = []
690
+ previous_handler = loop.get_exception_handler()
691
+
692
+ loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
693
+ try:
694
+ result, error = await conn.call_tool("self.robot.set_head_angles", {})
695
+ gc.collect()
696
+ await asyncio.sleep(0)
697
+ finally:
698
+ loop.set_exception_handler(previous_handler)
699
+
700
+ assert result is None
701
+ assert error is not None
702
+ assert "WebSocket send failed" in error["message"]
703
+ assert not conn.connected
704
+ assert conn._pending == {}
705
+ assert ws.send_calls == 1
706
+ assert loop_errors == []
707
+
708
+
446
709
  def test_connection_default_protocol_version_is_one():
447
710
  """Fresh ESP32Connection defaults to WebSocket protocol v1.
448
711
 
@@ -2009,7 +2009,7 @@ wheels = [
2009
2009
 
2010
2010
  [[package]]
2011
2011
  name = "stackchan-mcp"
2012
- version = "0.7.0"
2012
+ version = "0.8.0"
2013
2013
  source = { editable = "." }
2014
2014
  dependencies = [
2015
2015
  { name = "aiohttp" },
File without changes
File without changes
File without changes