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.
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/PKG-INFO +1 -1
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/pyproject.toml +1 -1
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/esp32_client.py +91 -5
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stdio_server.py +176 -3
- stackchan_mcp-0.8.0/stackchan_mcp/stt/orchestrator.py +552 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_audio_utils.py +2 -1
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_esp32_client.py +264 -1
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_stdio_server.py +194 -0
- stackchan_mcp-0.8.0/tests/test_stt_orchestrator.py +1150 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/uv.lock +1 -1
- stackchan_mcp-0.6.0/stackchan_mcp/stt/orchestrator.py +0 -306
- stackchan_mcp-0.6.0/tests/test_stt_orchestrator.py +0 -441
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/.env.example +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/.gitignore +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/LICENSE +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/README.md +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/cli.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/__init__.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/base.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/orchestrator.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_cli.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_orchestrator.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.6.0 → stackchan_mcp-0.8.0}/tests/test_tts_framework.py +0 -0
- {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.
|
|
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
|
|
@@ -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.
|
|
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
|
-
) ->
|
|
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
|
|
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
|
|
490
|
-
|
|
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
|
|
107
|
-
"yaw: horizontal (-90 to 90), pitch: vertical (
|
|
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":
|
|
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
|
{},
|