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.
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/PKG-INFO +26 -2
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/README.md +25 -1
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/pyproject.toml +1 -1
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/stdio_server.py +72 -1
- stackchan_mcp-0.4.0/tests/test_stdio_server.py +148 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/uv.lock +1 -1
- stackchan_mcp-0.3.0/tests/test_stdio_server.py +0 -66
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/.env.example +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/.gitignore +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/LICENSE +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/cli.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/esp32_client.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_cli.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_esp32_client.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.3.0 → stackchan_mcp-0.4.0}/tests/test_mcp_router.py +0 -0
- {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
|
+
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
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|