stackchan-mcp 0.6.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 (53) hide show
  1. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/PKG-INFO +1 -1
  2. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/pyproject.toml +1 -1
  3. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/esp32_client.py +91 -5
  4. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stdio_server.py +176 -3
  5. stackchan_mcp-0.8.0/stackchan_mcp/stt/orchestrator.py +552 -0
  6. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_audio_utils.py +2 -1
  7. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_esp32_client.py +264 -1
  8. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_stdio_server.py +194 -0
  9. stackchan_mcp-0.8.0/tests/test_stt_orchestrator.py +1150 -0
  10. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/uv.lock +1 -1
  11. stackchan_mcp-0.6.0/stackchan_mcp/stt/orchestrator.py +0 -306
  12. stackchan_mcp-0.6.0/tests/test_stt_orchestrator.py +0 -441
  13. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/.env.example +0 -0
  14. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/.gitignore +0 -0
  15. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/LICENSE +0 -0
  16. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/README.md +0 -0
  17. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/__init__.py +0 -0
  18. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/__main__.py +0 -0
  19. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/audio_stream.py +0 -0
  20. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/capture_server.py +0 -0
  21. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/cli.py +0 -0
  22. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/gateway.py +0 -0
  23. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/__init__.py +0 -0
  24. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/audio.py +0 -0
  25. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/camera.py +0 -0
  26. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/robot.py +0 -0
  27. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/mcp_router.py +0 -0
  28. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/protocol.py +0 -0
  29. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/server.py +0 -0
  30. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/__init__.py +0 -0
  31. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  32. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/base.py +0 -0
  33. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  34. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  35. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tools.py +0 -0
  36. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/__init__.py +0 -0
  37. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  38. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/base.py +0 -0
  39. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/orchestrator.py +0 -0
  40. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/voicevox.py +0 -0
  41. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/_audio_fixtures.py +0 -0
  42. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/conftest.py +0 -0
  43. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_audio_stream.py +0 -0
  44. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_capture_server.py +0 -0
  45. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_cli.py +0 -0
  46. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_gateway.py +0 -0
  47. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_mcp_router.py +0 -0
  48. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_orchestrator.py +0 -0
  49. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_protocol.py +0 -0
  50. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_stt_audio_utils.py +0 -0
  51. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_stt_framework.py +0 -0
  52. {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_tts_framework.py +0 -0
  53. {stackchan_mcp-0.6.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.6.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.6.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.
@@ -103,8 +103,14 @@ def create_server() -> Server:
103
103
  Tool(
104
104
  name="move_head",
105
105
  description=(
106
- "Move the robot's head to the specified angles. "
107
- "yaw: horizontal (-90 to 90), pitch: vertical (-30 to 30)."
106
+ "Move the robot's head to safe, recommended angles. "
107
+ "yaw: horizontal (-90 to 90), pitch: vertical (5 to 85, "
108
+ "the M5Stack-recommended operating range). Out-of-range "
109
+ "requests are rejected at this MCP layer; for advanced "
110
+ "callers that need the firmware hard clamp (pitch 0..88), "
111
+ "use the firmware-side `set_head_angles` device tool, "
112
+ "which exposes a permissive schema and the authoritative "
113
+ "two-tier guard described in the README."
108
114
  ),
109
115
  inputSchema={
110
116
  "type": "object",
@@ -112,10 +118,19 @@ def create_server() -> Server:
112
118
  "yaw": {
113
119
  "type": "integer",
114
120
  "description": "Horizontal angle in degrees (-90 to 90)",
121
+ "minimum": -90,
122
+ "maximum": 90,
115
123
  },
116
124
  "pitch": {
117
125
  "type": "integer",
118
- "description": "Vertical angle in degrees (-30 to 30)",
126
+ "description": (
127
+ "Vertical angle in degrees (5 to 85, "
128
+ "M5Stack-recommended operating range). For the "
129
+ "wider firmware hard clamp (0..88), use the "
130
+ "`set_head_angles` device tool instead."
131
+ ),
132
+ "minimum": 5,
133
+ "maximum": 85,
119
134
  },
120
135
  },
121
136
  "required": ["yaw", "pitch"],
@@ -281,6 +296,77 @@ def create_server() -> Server:
281
296
  "required": ["enabled"],
282
297
  },
283
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
+ ),
284
370
  Tool(
285
371
  name="get_touch_state",
286
372
  description=(
@@ -422,6 +508,9 @@ def create_server() -> Server:
422
508
  "minimal firmware change to handle the inbound 'listen' "
423
509
  "wire type (paired with this gateway release). Engine is "
424
510
  "selectable via 'engine' (default 'faster-whisper', local). "
511
+ "Optional 'motion' feedback can switch the avatar to "
512
+ "'thinking' during capture ('face-only') or tilt the head "
513
+ "up while preserving yaw ('look-up'). "
425
514
  "Install the relevant extra "
426
515
  "('pip install stackchan-mcp[stt-faster-whisper]' or "
427
516
  "'stt-openai'); calling this tool before an engine is "
@@ -465,6 +554,29 @@ def create_server() -> Server:
465
554
  "fall back to their default when omitted."
466
555
  ),
467
556
  },
557
+ "motion": {
558
+ "type": "string",
559
+ "enum": ["none", "face-only", "look-up"],
560
+ "description": (
561
+ "Optional visible feedback during capture. "
562
+ "'none' preserves the previous behaviour. "
563
+ "'face-only' shows the thinking avatar during "
564
+ "capture and restores idle at the end. "
565
+ "'look-up' preserves yaw, tilts pitch to "
566
+ "look_up_pitch, and holds the pose on success."
567
+ ),
568
+ "default": "none",
569
+ },
570
+ "look_up_pitch": {
571
+ "type": "number",
572
+ "description": (
573
+ "Pitch angle for motion='look-up'. Must be "
574
+ "between 5 and 85 degrees."
575
+ ),
576
+ "default": 50.0,
577
+ "minimum": 5,
578
+ "maximum": 85,
579
+ },
468
580
  },
469
581
  },
470
582
  ),
@@ -526,6 +638,59 @@ def create_server() -> Server:
526
638
  )
527
639
  ]
528
640
 
641
+ if name == "move_head":
642
+ # Belt-and-suspenders validation for the recommended pitch range.
643
+ # The Tool inputSchema already declares minimum/maximum for both
644
+ # yaw and pitch, but mcp Python SDK server-side enforcement of
645
+ # JSON Schema bounds is not guaranteed across versions and
646
+ # clients. Reject out-of-recommended values here as a clean
647
+ # MCP error JSON before any motion command reaches the device.
648
+ # Callers that genuinely need the firmware hard clamp 0..88
649
+ # should use the firmware-side `set_head_angles` device tool,
650
+ # which exposes the authoritative two-tier guard described in
651
+ # the README "Y-axis (pitch) safe range" section.
652
+ yaw_val = arguments.get("yaw")
653
+ pitch_val = arguments.get("pitch")
654
+ if (
655
+ not isinstance(yaw_val, int)
656
+ or isinstance(yaw_val, bool)
657
+ or not (-90 <= yaw_val <= 90)
658
+ ):
659
+ return [
660
+ TextContent(
661
+ type="text",
662
+ text=json.dumps(
663
+ {
664
+ "error": (
665
+ "yaw must be an integer in -90..90 "
666
+ f"(got {yaw_val!r})"
667
+ )
668
+ }
669
+ ),
670
+ )
671
+ ]
672
+ if (
673
+ not isinstance(pitch_val, int)
674
+ or isinstance(pitch_val, bool)
675
+ or not (5 <= pitch_val <= 85)
676
+ ):
677
+ return [
678
+ TextContent(
679
+ type="text",
680
+ text=json.dumps(
681
+ {
682
+ "error": (
683
+ "pitch must be an integer in 5..85 "
684
+ "(M5Stack-recommended operating range; "
685
+ "for the wider firmware hard clamp "
686
+ "0..88 use `set_head_angles`). got "
687
+ f"{pitch_val!r}"
688
+ )
689
+ }
690
+ ),
691
+ )
692
+ ]
693
+
529
694
  # Map MCP client tool names to ESP32 MCP tool names (self.* prefix)
530
695
  tool_map: dict[str, tuple[str, dict[str, Any]]] = {
531
696
  "get_device_info": (
@@ -583,6 +748,14 @@ def create_server() -> Server:
583
748
  "self.display.set_blink",
584
749
  arguments,
585
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
+ ),
586
759
  "get_touch_state": (
587
760
  "self.touch.get_touch_state",
588
761
  {},