stackchan-mcp 0.3.0__tar.gz → 0.4.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 (32) hide show
  1. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/PKG-INFO +26 -2
  2. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/README.md +25 -1
  3. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/pyproject.toml +1 -1
  4. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/stdio_server.py +72 -1
  5. stackchan_mcp-0.4.0/tests/test_stdio_server.py +148 -0
  6. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/uv.lock +1 -1
  7. stackchan_mcp-0.3.0/tests/test_stdio_server.py +0 -66
  8. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/.env.example +0 -0
  9. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/.gitignore +0 -0
  10. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/LICENSE +0 -0
  11. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/__init__.py +0 -0
  12. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/__main__.py +0 -0
  13. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/audio_stream.py +0 -0
  14. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/capture_server.py +0 -0
  15. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/cli.py +0 -0
  16. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/esp32_client.py +0 -0
  17. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/gateway.py +0 -0
  18. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/handlers/__init__.py +0 -0
  19. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/handlers/audio.py +0 -0
  20. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/handlers/camera.py +0 -0
  21. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/handlers/robot.py +0 -0
  22. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/mcp_router.py +0 -0
  23. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/protocol.py +0 -0
  24. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/server.py +0 -0
  25. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/tools.py +0 -0
  26. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/conftest.py +0 -0
  27. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_capture_server.py +0 -0
  28. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_cli.py +0 -0
  29. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_esp32_client.py +0 -0
  30. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_gateway.py +0 -0
  31. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_mcp_router.py +0 -0
  32. {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackchan-mcp
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -114,6 +114,29 @@ will notice the dropped WebSocket and retry while idle. The retry delay starts
114
114
  at 5 seconds and backs off up to 60 seconds. After the gateway is listening
115
115
  again, check `get_status` from the stdio MCP side to confirm the device is back.
116
116
 
117
+ ## Configuration changes
118
+
119
+ The gateway reads `.env` once at process start. Because the gateway runs as a
120
+ **stdio MCP server** (it has no standalone CLI mode beyond `--help` /
121
+ `--version` / `--check`), editing `.env` while it is connected to an MCP
122
+ client does not take effect on the running process — and killing the gateway
123
+ process directly will not auto-restart it; the MCP client owns the lifecycle.
124
+
125
+ After editing `.env` (for example to update `STACKCHAN_TOKEN`, `VISION_URL`,
126
+ or `VISION_TOKEN`):
127
+
128
+ 1. Reconnect the MCP client. In Claude Code this is `/mcp` to reconnect, or a
129
+ full Claude Code restart.
130
+ 2. Confirm `mcp__stackchan-mcp__get_status` returns `connected: true` with the
131
+ expected `tools_count`.
132
+ 3. If the ESP32 was already connected with a stale auth credential, hard-reset
133
+ the device (`esptool.py --before default_reset --after hard_reset chip_id`,
134
+ or DTR/RTS toggle via pyserial) so it reconnects with the fresh
135
+ configuration.
136
+
137
+ `STACKCHAN_TOKEN` takes precedence over the legacy `BEARER_TOKEN`; setting
138
+ either is enough, but if you have both, keep them aligned.
139
+
117
140
  ## Tests
118
141
 
119
142
  ```bash
@@ -165,7 +188,8 @@ Same shape, under `mcpServers`.
165
188
  | `get_touch_state` | Touch sensor state (press/release/stroke) |
166
189
  | `set_avatar(face)` | Switch avatar expression (`idle` / `happy` / `thinking` / `sad` / `surprised` / `embarrassed`), or `off` to hide the avatar and disable blink so the underlying xiaozhi-esp32 screens (WiFi config UI, OTA, settings) are visible. A subsequent `set_avatar(<other face>)` brings it back and restores blink. |
167
190
  | `set_blink(state)` | Blink animation on/off |
168
- | `set_mouth(state)` | Mouth shape (`closed` / `half` / `open` / `e` / `u`) |
191
+ | `set_mouth(state)` | Mouth shape (`closed` / `half` / `open` / `e` / `u`), one-shot, held until next call |
192
+ | `set_mouth_sequence(steps)` | Queue and play a list of `{shape, duration_ms}` steps locally for TTS lip-sync. The firmware walks the queue without per-step network RTT. Calling `set_mouth`, `set_avatar`, or this tool again interrupts the in-flight sequence; autonomous blink is paused while a sequence is playing. |
169
193
  | `check_vm_en` | Read PY32 VM EN GPIO state (servo power supply diagnostic) |
170
194
 
171
195
  The mapping from these names to ESP32-side `self.*` MCP tools is in
@@ -83,6 +83,29 @@ will notice the dropped WebSocket and retry while idle. The retry delay starts
83
83
  at 5 seconds and backs off up to 60 seconds. After the gateway is listening
84
84
  again, check `get_status` from the stdio MCP side to confirm the device is back.
85
85
 
86
+ ## Configuration changes
87
+
88
+ The gateway reads `.env` once at process start. Because the gateway runs as a
89
+ **stdio MCP server** (it has no standalone CLI mode beyond `--help` /
90
+ `--version` / `--check`), editing `.env` while it is connected to an MCP
91
+ client does not take effect on the running process — and killing the gateway
92
+ process directly will not auto-restart it; the MCP client owns the lifecycle.
93
+
94
+ After editing `.env` (for example to update `STACKCHAN_TOKEN`, `VISION_URL`,
95
+ or `VISION_TOKEN`):
96
+
97
+ 1. Reconnect the MCP client. In Claude Code this is `/mcp` to reconnect, or a
98
+ full Claude Code restart.
99
+ 2. Confirm `mcp__stackchan-mcp__get_status` returns `connected: true` with the
100
+ expected `tools_count`.
101
+ 3. If the ESP32 was already connected with a stale auth credential, hard-reset
102
+ the device (`esptool.py --before default_reset --after hard_reset chip_id`,
103
+ or DTR/RTS toggle via pyserial) so it reconnects with the fresh
104
+ configuration.
105
+
106
+ `STACKCHAN_TOKEN` takes precedence over the legacy `BEARER_TOKEN`; setting
107
+ either is enough, but if you have both, keep them aligned.
108
+
86
109
  ## Tests
87
110
 
88
111
  ```bash
@@ -134,7 +157,8 @@ Same shape, under `mcpServers`.
134
157
  | `get_touch_state` | Touch sensor state (press/release/stroke) |
135
158
  | `set_avatar(face)` | Switch avatar expression (`idle` / `happy` / `thinking` / `sad` / `surprised` / `embarrassed`), or `off` to hide the avatar and disable blink so the underlying xiaozhi-esp32 screens (WiFi config UI, OTA, settings) are visible. A subsequent `set_avatar(<other face>)` brings it back and restores blink. |
136
159
  | `set_blink(state)` | Blink animation on/off |
137
- | `set_mouth(state)` | Mouth shape (`closed` / `half` / `open` / `e` / `u`) |
160
+ | `set_mouth(state)` | Mouth shape (`closed` / `half` / `open` / `e` / `u`), one-shot, held until next call |
161
+ | `set_mouth_sequence(steps)` | Queue and play a list of `{shape, duration_ms}` steps locally for TTS lip-sync. The firmware walks the queue without per-step network RTT. Calling `set_mouth`, `set_avatar`, or this tool again interrupts the in-flight sequence; autonomous blink is paused while a sequence is playing. |
138
162
  | `check_vm_en` | Read PY32 VM EN GPIO state (servo power supply diagnostic) |
139
163
 
140
164
  The mapping from these names to ESP32-side `self.*` MCP tools is in
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackchan-mcp"
3
- version = "0.3.0"
3
+ version = "0.4.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"
@@ -184,7 +184,9 @@ def create_server() -> Server:
184
184
  description=(
185
185
  "Set the avatar mouth shape for lip-sync. "
186
186
  "The shape is held until the next set_avatar / set_mouth call, "
187
- "or until an autonomous blink restores the resting face."
187
+ "or until an autonomous blink restores the resting face. "
188
+ "Calling this while a set_mouth_sequence is in flight "
189
+ "interrupts the sequence."
188
190
  ),
189
191
  inputSchema={
190
192
  "type": "object",
@@ -198,6 +200,68 @@ def create_server() -> Server:
198
200
  "required": ["mouth"],
199
201
  },
200
202
  ),
203
+ Tool(
204
+ name="set_mouth_sequence",
205
+ description=(
206
+ "Queue a lip-sync sequence and play it on the device. "
207
+ "Each step holds 'shape' for 'duration_ms' before "
208
+ "advancing. The firmware walks the queue locally so "
209
+ "there is no per-step network RTT (use this instead of "
210
+ "issuing many set_mouth calls back-to-back from a TTS "
211
+ "loop). Returns immediately with the queued step count "
212
+ "and estimated total duration. Calling set_mouth, "
213
+ "set_avatar, or this tool again interrupts the in-flight "
214
+ "sequence and replaces it. Autonomous blink is paused "
215
+ "while a sequence is playing and resumed when it ends. "
216
+ "The final shape is held until the next "
217
+ "set_mouth / set_avatar call, or until an autonomous "
218
+ "blink restores the resting face — this is the same "
219
+ "Phase 2 trade-off that applies to set_mouth, since the "
220
+ "blink animation ends by repainting the full face. If "
221
+ "the final shape must persist visually, disable blink "
222
+ "with set_blink(false) before the sequence (or append a "
223
+ "closed step if you just want the mouth to close at "
224
+ "the end)."
225
+ ),
226
+ inputSchema={
227
+ "type": "object",
228
+ "properties": {
229
+ "steps": {
230
+ "type": "array",
231
+ "minItems": 1,
232
+ "maxItems": 256,
233
+ "items": {
234
+ "type": "object",
235
+ "properties": {
236
+ "shape": {
237
+ "type": "string",
238
+ "enum": ["closed", "half", "open", "e", "u"],
239
+ "description": (
240
+ "Mouth shape for this step. "
241
+ "One of: closed, half, open, e, u."
242
+ ),
243
+ },
244
+ "duration_ms": {
245
+ "type": "integer",
246
+ "minimum": 10,
247
+ "maximum": 10000,
248
+ "description": (
249
+ "How long to hold this shape "
250
+ "before advancing, in ms (10..10000)."
251
+ ),
252
+ },
253
+ },
254
+ "required": ["shape", "duration_ms"],
255
+ },
256
+ "description": (
257
+ "Ordered list of mouth shapes with hold "
258
+ "durations (1..256 steps)."
259
+ ),
260
+ },
261
+ },
262
+ "required": ["steps"],
263
+ },
264
+ ),
201
265
  Tool(
202
266
  name="set_blink",
203
267
  description=(
@@ -294,6 +358,13 @@ def create_server() -> Server:
294
358
  "self.display.set_mouth",
295
359
  arguments,
296
360
  ),
361
+ # The MCP Property type system on ESP32 only supports
362
+ # string/integer/boolean, so we serialise the steps array to
363
+ # a JSON string here. The firmware decodes it via cJSON.
364
+ "set_mouth_sequence": (
365
+ "self.display.set_mouth_sequence",
366
+ {"steps_json": json.dumps(arguments.get("steps", []))},
367
+ ),
297
368
  "set_blink": (
298
369
  "self.display.set_blink",
299
370
  arguments,
@@ -0,0 +1,148 @@
1
+ """Tests for stdio MCP server tool definitions."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from mcp.types import CallToolRequest, ListToolsRequest
7
+
8
+ from stackchan_mcp.stdio_server import create_server
9
+
10
+
11
+ def test_create_server():
12
+ """Server creation succeeds with correct name."""
13
+ server = create_server()
14
+ assert server is not None
15
+ assert server.name == "stackchan-mcp"
16
+
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_list_tools_includes_get_head_angles():
20
+ """get_head_angles is exposed to MCP clients."""
21
+ server = create_server()
22
+
23
+ result = await server.request_handlers[ListToolsRequest](
24
+ ListToolsRequest(method="tools/list")
25
+ )
26
+
27
+ tool_names = [tool.name for tool in result.root.tools]
28
+ assert "get_head_angles" in tool_names
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_get_head_angles_relays_to_esp32(monkeypatch):
33
+ """get_head_angles maps to the ESP32 self.robot.get_head_angles tool."""
34
+ calls = []
35
+
36
+ class FakeESP32:
37
+ device_connected = True
38
+
39
+ async def call_tool(self, name, arguments):
40
+ calls.append((name, arguments))
41
+ return {
42
+ "content": [
43
+ {
44
+ "type": "text",
45
+ "text": json.dumps({"yaw": 12, "pitch": -3}),
46
+ }
47
+ ],
48
+ }, None
49
+
50
+ class FakeGateway:
51
+ esp32 = FakeESP32()
52
+
53
+ import stackchan_mcp.stdio_server as stdio_server
54
+
55
+ monkeypatch.setattr(stdio_server, "get_gateway", lambda: FakeGateway())
56
+ server = create_server()
57
+
58
+ result = await server.request_handlers[CallToolRequest](
59
+ CallToolRequest(
60
+ method="tools/call",
61
+ params={"name": "get_head_angles", "arguments": {}},
62
+ )
63
+ )
64
+
65
+ assert calls == [("self.robot.get_head_angles", {})]
66
+ assert json.loads(result.root.content[0].text) == {"yaw": 12, "pitch": -3}
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_list_tools_includes_set_mouth_sequence():
71
+ """set_mouth_sequence is exposed to MCP clients with an array schema."""
72
+ server = create_server()
73
+
74
+ result = await server.request_handlers[ListToolsRequest](
75
+ ListToolsRequest(method="tools/list")
76
+ )
77
+
78
+ tool = next((t for t in result.root.tools if t.name == "set_mouth_sequence"), None)
79
+ assert tool is not None, "set_mouth_sequence tool should be registered"
80
+
81
+ schema = tool.inputSchema
82
+ assert schema["properties"]["steps"]["type"] == "array"
83
+ assert schema["properties"]["steps"]["minItems"] == 1
84
+ assert schema["properties"]["steps"]["maxItems"] == 256
85
+
86
+ item_schema = schema["properties"]["steps"]["items"]
87
+ assert item_schema["properties"]["shape"]["enum"] == [
88
+ "closed",
89
+ "half",
90
+ "open",
91
+ "e",
92
+ "u",
93
+ ]
94
+ assert item_schema["properties"]["duration_ms"]["minimum"] == 10
95
+ assert item_schema["properties"]["duration_ms"]["maximum"] == 10000
96
+ assert set(item_schema["required"]) == {"shape", "duration_ms"}
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_set_mouth_sequence_relays_steps_as_json_string(monkeypatch):
101
+ """set_mouth_sequence serialises steps to JSON for the firmware.
102
+
103
+ The ESP32 MCP Property type system only supports string/integer/boolean,
104
+ so the gateway flattens the steps array to a JSON string under
105
+ `steps_json` before forwarding to self.display.set_mouth_sequence.
106
+ """
107
+ calls = []
108
+
109
+ class FakeESP32:
110
+ device_connected = True
111
+
112
+ async def call_tool(self, name, arguments):
113
+ calls.append((name, arguments))
114
+ return {
115
+ "content": [
116
+ {
117
+ "type": "text",
118
+ "text": json.dumps(
119
+ {"ok": True, "queued_steps": 2, "estimated_duration_ms": 160}
120
+ ),
121
+ }
122
+ ],
123
+ }, None
124
+
125
+ class FakeGateway:
126
+ esp32 = FakeESP32()
127
+
128
+ import stackchan_mcp.stdio_server as stdio_server
129
+
130
+ monkeypatch.setattr(stdio_server, "get_gateway", lambda: FakeGateway())
131
+ server = create_server()
132
+
133
+ steps = [
134
+ {"shape": "open", "duration_ms": 80},
135
+ {"shape": "closed", "duration_ms": 80},
136
+ ]
137
+ await server.request_handlers[CallToolRequest](
138
+ CallToolRequest(
139
+ method="tools/call",
140
+ params={"name": "set_mouth_sequence", "arguments": {"steps": steps}},
141
+ )
142
+ )
143
+
144
+ assert len(calls) == 1
145
+ name, arguments = calls[0]
146
+ assert name == "self.display.set_mouth_sequence"
147
+ assert set(arguments.keys()) == {"steps_json"}
148
+ assert json.loads(arguments["steps_json"]) == steps
@@ -1313,7 +1313,7 @@ wheels = [
1313
1313
 
1314
1314
  [[package]]
1315
1315
  name = "stackchan-mcp"
1316
- version = "0.3.0"
1316
+ version = "0.4.0"
1317
1317
  source = { editable = "." }
1318
1318
  dependencies = [
1319
1319
  { name = "aiohttp" },
@@ -1,66 +0,0 @@
1
- """Tests for stdio MCP server tool definitions."""
2
-
3
- import json
4
-
5
- import pytest
6
- from mcp.types import CallToolRequest, ListToolsRequest
7
-
8
- from stackchan_mcp.stdio_server import create_server
9
-
10
-
11
- def test_create_server():
12
- """Server creation succeeds with correct name."""
13
- server = create_server()
14
- assert server is not None
15
- assert server.name == "stackchan-mcp"
16
-
17
-
18
- @pytest.mark.asyncio
19
- async def test_list_tools_includes_get_head_angles():
20
- """get_head_angles is exposed to MCP clients."""
21
- server = create_server()
22
-
23
- result = await server.request_handlers[ListToolsRequest](
24
- ListToolsRequest(method="tools/list")
25
- )
26
-
27
- tool_names = [tool.name for tool in result.root.tools]
28
- assert "get_head_angles" in tool_names
29
-
30
-
31
- @pytest.mark.asyncio
32
- async def test_get_head_angles_relays_to_esp32(monkeypatch):
33
- """get_head_angles maps to the ESP32 self.robot.get_head_angles tool."""
34
- calls = []
35
-
36
- class FakeESP32:
37
- device_connected = True
38
-
39
- async def call_tool(self, name, arguments):
40
- calls.append((name, arguments))
41
- return {
42
- "content": [
43
- {
44
- "type": "text",
45
- "text": json.dumps({"yaw": 12, "pitch": -3}),
46
- }
47
- ],
48
- }, None
49
-
50
- class FakeGateway:
51
- esp32 = FakeESP32()
52
-
53
- import stackchan_mcp.stdio_server as stdio_server
54
-
55
- monkeypatch.setattr(stdio_server, "get_gateway", lambda: FakeGateway())
56
- server = create_server()
57
-
58
- result = await server.request_handlers[CallToolRequest](
59
- CallToolRequest(
60
- method="tools/call",
61
- params={"name": "get_head_angles", "arguments": {}},
62
- )
63
- )
64
-
65
- assert calls == [("self.robot.get_head_angles", {})]
66
- assert json.loads(result.root.content[0].text) == {"yaw": 12, "pitch": -3}
File without changes
File without changes