stackchan-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,25 @@
1
+ """Camera handler: take_photo via ESP32 relay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ async def take_photo(esp32_call) -> dict[str, Any]:
12
+ """Take a photo via ESP32 camera.
13
+
14
+ Args:
15
+ esp32_call: async callable (name, arguments) -> (result, error)
16
+
17
+ Returns:
18
+ MCP result content.
19
+ """
20
+ result, error = await esp32_call(
21
+ "self.camera.take_photo", {}
22
+ )
23
+ if error:
24
+ raise RuntimeError(f"take_photo failed: {error.get('message', str(error))}")
25
+ return result
@@ -0,0 +1,52 @@
1
+ """Robot handlers: servo and LED (stub implementation with in-memory state)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from ..tools import SetHeadAnglesParams, SetLedColorParams
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # In-memory device state
14
+ # ---------------------------------------------------------------------------
15
+
16
+ _head_angles: dict[str, int] = {"yaw": 0, "pitch": 0}
17
+ _led_color: dict[str, int] = {"r": 0, "g": 0, "b": 0}
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Handlers
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def get_head_angles(_args: dict[str, Any] | None = None) -> dict[str, int]:
26
+ """Return current head angles."""
27
+ logger.info("get_head_angles -> %s", _head_angles)
28
+ return dict(_head_angles)
29
+
30
+
31
+ def set_head_angles(args: dict[str, Any]) -> bool:
32
+ """Set head angles (in-memory stub)."""
33
+ params = SetHeadAnglesParams(**args)
34
+ _head_angles["yaw"] = params.yaw
35
+ _head_angles["pitch"] = params.pitch
36
+ logger.info(
37
+ "set_head_angles yaw=%d pitch=%d speed=%d",
38
+ params.yaw,
39
+ params.pitch,
40
+ params.speed,
41
+ )
42
+ return True
43
+
44
+
45
+ def set_led_color(args: dict[str, Any]) -> bool:
46
+ """Set LED color (in-memory stub)."""
47
+ params = SetLedColorParams(**args)
48
+ _led_color["r"] = params.r
49
+ _led_color["g"] = params.g
50
+ _led_color["b"] = params.b
51
+ logger.info("set_led_color r=%d g=%d b=%d", params.r, params.g, params.b)
52
+ return True
@@ -0,0 +1,126 @@
1
+ """JSON-RPC 2.0 router for MCP protocol.
2
+
3
+ Handles: initialize, tools/list, tools/call
4
+
5
+ In gateway mode, tools/call is relayed to the ESP32 device.
6
+ For testing without ESP32, use route() with local stub handlers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ from typing import Any
14
+
15
+ from .tools import TOOL_DEFINITIONS
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # MCP server capabilities
22
+ # ---------------------------------------------------------------------------
23
+
24
+ SERVER_INFO = {
25
+ "name": "stackchan-mcp",
26
+ "version": "0.1.0",
27
+ }
28
+
29
+ SERVER_CAPABILITIES = {
30
+ "tools": {},
31
+ }
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # JSON-RPC helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _ok(req_id: Any, result: Any) -> dict[str, Any]:
40
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
41
+
42
+
43
+ def _error(req_id: Any, code: int, message: str) -> dict[str, Any]:
44
+ return {
45
+ "jsonrpc": "2.0",
46
+ "id": req_id,
47
+ "error": {"code": code, "message": message},
48
+ }
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Router (local stub mode for testing)
53
+ # ---------------------------------------------------------------------------
54
+
55
+ # Lazy import to avoid circular dependency
56
+ _TOOL_HANDLERS: dict[str, Any] | None = None
57
+
58
+
59
+ def _get_tool_handlers() -> dict[str, Any]:
60
+ global _TOOL_HANDLERS
61
+ if _TOOL_HANDLERS is None:
62
+ from .handlers.audio import set_volume
63
+ from .handlers.robot import get_head_angles, set_head_angles, set_led_color
64
+
65
+ _TOOL_HANDLERS = {
66
+ "self.robot.get_head_angles": get_head_angles,
67
+ "self.robot.set_head_angles": set_head_angles,
68
+ "self.robot.set_led_color": set_led_color,
69
+ "self.audio_speaker.set_volume": set_volume,
70
+ }
71
+ return _TOOL_HANDLERS
72
+
73
+
74
+ def route(payload: dict[str, Any]) -> dict[str, Any]:
75
+ """Route a single JSON-RPC 2.0 request and return a response dict.
76
+
77
+ This is the local stub mode — tool calls are handled in-process.
78
+ """
79
+ req_id = payload.get("id")
80
+ method = payload.get("method", "")
81
+ params = payload.get("params", {})
82
+
83
+ logger.info("mcp method=%s id=%s", method, req_id)
84
+
85
+ if method == "initialize":
86
+ return _ok(
87
+ req_id,
88
+ {
89
+ "protocolVersion": "2024-11-05",
90
+ "serverInfo": SERVER_INFO,
91
+ "capabilities": SERVER_CAPABILITIES,
92
+ },
93
+ )
94
+
95
+ if method == "tools/list":
96
+ return _ok(req_id, {"tools": TOOL_DEFINITIONS})
97
+
98
+ if method == "tools/call":
99
+ tool_name = params.get("name", "")
100
+ arguments = params.get("arguments", {})
101
+ handlers = _get_tool_handlers()
102
+ handler = handlers.get(tool_name)
103
+
104
+ if handler is None:
105
+ return _error(req_id, -32601, f"Unknown tool: {tool_name}")
106
+
107
+ try:
108
+ result = handler(arguments) if arguments else handler()
109
+ return _ok(
110
+ req_id,
111
+ {
112
+ "content": [
113
+ {
114
+ "type": "text",
115
+ "text": json.dumps(result)
116
+ if not isinstance(result, str)
117
+ else result,
118
+ }
119
+ ],
120
+ },
121
+ )
122
+ except Exception as exc:
123
+ logger.exception("tools/call %s failed", tool_name)
124
+ return _error(req_id, -32000, str(exc))
125
+
126
+ return _error(req_id, -32601, f"Method not found: {method}")
@@ -0,0 +1,95 @@
1
+ """xiaozhi-esp32 protocol definitions.
2
+
3
+ Defines message formats for communication between the gateway and ESP32 device.
4
+ Based on: xiaozhi-esp32/docs/mcp-protocol.md
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Transport-level messages (hello handshake)
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ class AudioParams(BaseModel):
20
+ format: str = "opus"
21
+ sample_rate: int = 16000
22
+ channels: int = 1
23
+ frame_duration: int = 60
24
+
25
+
26
+ class HelloMessage(BaseModel):
27
+ """ESP32 -> Gateway: hello (connection announcement)."""
28
+
29
+ type: str = "hello"
30
+ version: int = 1
31
+ features: dict[str, Any] = Field(default_factory=lambda: {"mcp": True})
32
+ transport: str = "websocket"
33
+ audio_params: AudioParams = Field(default_factory=AudioParams)
34
+ session_id: str | None = None
35
+
36
+
37
+ class HelloResponse(BaseModel):
38
+ """Gateway -> ESP32: hello response."""
39
+
40
+ type: str = "hello"
41
+ version: int = 1
42
+ transport: str = "websocket"
43
+ session_id: str | None = None
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # MCP message wrapper (over transport)
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class McpMessage(BaseModel):
52
+ """MCP message wrapper for transport.
53
+
54
+ All MCP communication is wrapped in this envelope.
55
+ """
56
+
57
+ session_id: str = ""
58
+ type: str = "mcp"
59
+ payload: dict[str, Any] = Field(default_factory=dict)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # JSON-RPC 2.0 helpers
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def make_jsonrpc_request(method: str, params: dict[str, Any], req_id: int) -> dict[str, Any]:
68
+ """Create a JSON-RPC 2.0 request payload."""
69
+ return {
70
+ "jsonrpc": "2.0",
71
+ "method": method,
72
+ "params": params,
73
+ "id": req_id,
74
+ }
75
+
76
+
77
+ def make_mcp_message(
78
+ session_id: str, method: str, params: dict[str, Any], req_id: int
79
+ ) -> dict[str, Any]:
80
+ """Create a full MCP transport message with JSON-RPC payload."""
81
+ return {
82
+ "session_id": session_id,
83
+ "type": "mcp",
84
+ "payload": make_jsonrpc_request(method, params, req_id),
85
+ }
86
+
87
+
88
+ def parse_jsonrpc_response(payload: dict[str, Any]) -> tuple[Any, dict[str, Any] | None]:
89
+ """Parse a JSON-RPC 2.0 response.
90
+
91
+ Returns (result, error) — one of them will be None.
92
+ """
93
+ if "error" in payload:
94
+ return None, payload["error"]
95
+ return payload.get("result"), None
@@ -0,0 +1,28 @@
1
+ """WebSocket server for ESP32 connections.
2
+
3
+ This module is retained for backward compatibility and testing.
4
+ In production, use the Gateway (gateway.py) which orchestrates
5
+ both the ESP32 WebSocket server and the stdio MCP server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from .esp32_client import ESP32Manager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ async def run_server(host: str = "0.0.0.0", port: int = 8765) -> None:
18
+ """Start the ESP32 WebSocket server standalone (for testing)."""
19
+ manager = ESP32Manager()
20
+ await manager.start(host, port)
21
+ logger.info("ESP32 WebSocket server running on ws://%s:%d", host, port)
22
+
23
+ # Keep running until interrupted
24
+ try:
25
+ import asyncio
26
+ await asyncio.Future() # Run forever
27
+ finally:
28
+ await manager.stop()
@@ -0,0 +1,344 @@
1
+ """stdio MCP server for MCP client.
2
+
3
+ Exposes stackchan tools via the MCP Python SDK's stdio transport.
4
+ Each tool call is relayed to the connected ESP32 device.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from typing import Any
12
+
13
+ from mcp.server import Server
14
+ from mcp.server.stdio import stdio_server
15
+ from mcp.types import TextContent, Tool
16
+
17
+ from .gateway import get_gateway
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def create_server() -> Server:
23
+ """Create and configure the MCP server with tool handlers."""
24
+ server = Server("stackchan-mcp")
25
+
26
+ @server.list_tools()
27
+ async def list_tools() -> list[Tool]:
28
+ """List available stackchan tools.
29
+
30
+ Tools prefixed with ESP32 names (self.*) are relayed to the device.
31
+ get_status is handled locally by the gateway.
32
+ """
33
+ return [
34
+ Tool(
35
+ name="get_status",
36
+ description=(
37
+ "Get the gateway's connection status: whether ESP32 is connected, "
38
+ "device info, and list of available device tools."
39
+ ),
40
+ inputSchema={
41
+ "type": "object",
42
+ "properties": {},
43
+ },
44
+ ),
45
+ Tool(
46
+ name="get_device_info",
47
+ description=(
48
+ "Get real-time device information from ESP32: "
49
+ "battery level, speaker volume, screen brightness, network status, etc."
50
+ ),
51
+ inputSchema={
52
+ "type": "object",
53
+ "properties": {},
54
+ },
55
+ ),
56
+ Tool(
57
+ name="take_photo",
58
+ description=(
59
+ "Take a photo with the robot's camera and ask a question about it. "
60
+ "The device captures an image and returns an AI-generated description."
61
+ ),
62
+ inputSchema={
63
+ "type": "object",
64
+ "properties": {
65
+ "question": {
66
+ "type": "string",
67
+ "description": "Question to ask about the photo (e.g. 'What do you see?')",
68
+ },
69
+ },
70
+ "required": ["question"],
71
+ },
72
+ ),
73
+ Tool(
74
+ name="set_volume",
75
+ description="Set the speaker volume (0-100).",
76
+ inputSchema={
77
+ "type": "object",
78
+ "properties": {
79
+ "volume": {
80
+ "type": "integer",
81
+ "description": "Volume level (0-100)",
82
+ },
83
+ },
84
+ "required": ["volume"],
85
+ },
86
+ ),
87
+ Tool(
88
+ name="set_brightness",
89
+ description="Set the screen brightness (0-100).",
90
+ inputSchema={
91
+ "type": "object",
92
+ "properties": {
93
+ "brightness": {
94
+ "type": "integer",
95
+ "description": "Brightness level (0-100)",
96
+ },
97
+ },
98
+ "required": ["brightness"],
99
+ },
100
+ ),
101
+ Tool(
102
+ name="move_head",
103
+ description=(
104
+ "Move the robot's head to the specified angles. "
105
+ "yaw: horizontal (-90 to 90), pitch: vertical (-30 to 30)."
106
+ ),
107
+ inputSchema={
108
+ "type": "object",
109
+ "properties": {
110
+ "yaw": {
111
+ "type": "integer",
112
+ "description": "Horizontal angle in degrees (-90 to 90)",
113
+ },
114
+ "pitch": {
115
+ "type": "integer",
116
+ "description": "Vertical angle in degrees (-30 to 30)",
117
+ },
118
+ },
119
+ "required": ["yaw", "pitch"],
120
+ },
121
+ ),
122
+ Tool(
123
+ name="get_head_angles",
124
+ description="Get the robot's current head angles: yaw and pitch in degrees.",
125
+ inputSchema={
126
+ "type": "object",
127
+ "properties": {},
128
+ },
129
+ ),
130
+ Tool(
131
+ name="gpio_test",
132
+ description="Test GPIO6 pin by toggling HIGH/LOW 5 times. Check if servo reacts.",
133
+ inputSchema={"type": "object", "properties": {}},
134
+ ),
135
+ Tool(
136
+ name="uart_diag",
137
+ description="Send raw servo bytes via UART and report write result.",
138
+ inputSchema={"type": "object", "properties": {}},
139
+ ),
140
+ Tool(
141
+ name="check_vm_en",
142
+ description=(
143
+ "Diagnostic: read PY32 REG_GPIO_O_L and report whether VM EN "
144
+ "(pin 0 = servo power) is currently HIGH. Returns "
145
+ "{io_expander_present, i2c_read_ok, raw, vm_en_high}."
146
+ ),
147
+ inputSchema={"type": "object", "properties": {}},
148
+ ),
149
+ Tool(
150
+ name="set_avatar",
151
+ description=(
152
+ "Switch the avatar face shown on the LCD. "
153
+ "Pick the face that fits the current emotional beat — this is "
154
+ "the robot's actual visible expression, not just a label."
155
+ ),
156
+ inputSchema={
157
+ "type": "object",
158
+ "properties": {
159
+ "face": {
160
+ "type": "string",
161
+ "enum": [
162
+ "idle",
163
+ "happy",
164
+ "thinking",
165
+ "sad",
166
+ "surprised",
167
+ "embarrassed",
168
+ ],
169
+ "description": "One of: idle, happy, thinking, sad, surprised, embarrassed.",
170
+ },
171
+ },
172
+ "required": ["face"],
173
+ },
174
+ ),
175
+ Tool(
176
+ name="set_mouth",
177
+ description=(
178
+ "Set the avatar mouth shape for lip-sync. "
179
+ "The shape is held until the next set_avatar / set_mouth call, "
180
+ "or until an autonomous blink restores the resting face."
181
+ ),
182
+ inputSchema={
183
+ "type": "object",
184
+ "properties": {
185
+ "mouth": {
186
+ "type": "string",
187
+ "enum": ["closed", "half", "open", "e", "u"],
188
+ "description": "One of: closed, half, open, e, u.",
189
+ },
190
+ },
191
+ "required": ["mouth"],
192
+ },
193
+ ),
194
+ Tool(
195
+ name="set_blink",
196
+ description=(
197
+ "Enable or disable autonomous eye blinking. "
198
+ "When enabled, the avatar blinks every 3-6 seconds at random."
199
+ ),
200
+ inputSchema={
201
+ "type": "object",
202
+ "properties": {
203
+ "enabled": {
204
+ "type": "boolean",
205
+ "description": "True to start blinking, false to stop.",
206
+ },
207
+ },
208
+ "required": ["enabled"],
209
+ },
210
+ ),
211
+ Tool(
212
+ name="get_touch_state",
213
+ description=(
214
+ "Read the head-touch (Si12T) sensor state and the most recent "
215
+ "gesture event (tap/stroke/idle). Returns per-zone booleans, "
216
+ "the raw output byte, and how long ago the last event fired."
217
+ ),
218
+ inputSchema={
219
+ "type": "object",
220
+ "properties": {},
221
+ },
222
+ ),
223
+ ]
224
+
225
+ @server.call_tool()
226
+ async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]:
227
+ """Handle a tool call by relaying to ESP32."""
228
+ arguments = arguments or {}
229
+ gw = get_gateway()
230
+
231
+ if name == "get_status":
232
+ # get_status is handled locally — no ESP32 needed
233
+ status = gw.esp32.get_status()
234
+ return [TextContent(type="text", text=json.dumps(status, indent=2))]
235
+
236
+ if not gw.esp32.device_connected:
237
+ return [
238
+ TextContent(
239
+ type="text",
240
+ text=json.dumps({"error": "No ESP32 device connected. Please check the device."}),
241
+ )
242
+ ]
243
+
244
+ # Map MCP client tool names to ESP32 MCP tool names (self.* prefix)
245
+ tool_map: dict[str, tuple[str, dict[str, Any]]] = {
246
+ "get_device_info": (
247
+ "self.get_device_status",
248
+ {},
249
+ ),
250
+ "take_photo": (
251
+ "self.camera.take_photo",
252
+ arguments,
253
+ ),
254
+ "set_volume": (
255
+ "self.audio_speaker.set_volume",
256
+ arguments,
257
+ ),
258
+ "set_brightness": (
259
+ "self.screen.set_brightness",
260
+ arguments,
261
+ ),
262
+ "move_head": (
263
+ "self.robot.set_head_angles",
264
+ arguments,
265
+ ),
266
+ "get_head_angles": (
267
+ "self.robot.get_head_angles",
268
+ {},
269
+ ),
270
+ "gpio_test": (
271
+ "self.robot.gpio_test",
272
+ {},
273
+ ),
274
+ "uart_diag": (
275
+ "self.robot.uart_diag",
276
+ {},
277
+ ),
278
+ "check_vm_en": (
279
+ "self.robot.check_vm_en",
280
+ {},
281
+ ),
282
+ "set_avatar": (
283
+ "self.display.set_avatar",
284
+ arguments,
285
+ ),
286
+ "set_mouth": (
287
+ "self.display.set_mouth",
288
+ arguments,
289
+ ),
290
+ "set_blink": (
291
+ "self.display.set_blink",
292
+ arguments,
293
+ ),
294
+ "get_touch_state": (
295
+ "self.touch.get_touch_state",
296
+ {},
297
+ ),
298
+ }
299
+
300
+ if name not in tool_map:
301
+ return [
302
+ TextContent(
303
+ type="text",
304
+ text=json.dumps({"error": f"Unknown tool: {name}"}),
305
+ )
306
+ ]
307
+
308
+ esp32_name, esp32_args = tool_map[name]
309
+ result, error = await gw.esp32.call_tool(esp32_name, esp32_args)
310
+
311
+ if error:
312
+ return [
313
+ TextContent(
314
+ type="text",
315
+ text=json.dumps({"error": error.get("message", str(error))}),
316
+ )
317
+ ]
318
+
319
+ # result from ESP32 is MCP format: {"content": [...], "isError": bool}
320
+ if isinstance(result, dict):
321
+ content = result.get("content", [])
322
+ if content and isinstance(content, list):
323
+ # Pass through content items as text
324
+ texts = []
325
+ for item in content:
326
+ if isinstance(item, dict) and item.get("type") == "text":
327
+ texts.append(item.get("text", ""))
328
+ if texts:
329
+ return [TextContent(type="text", text="\n".join(texts))]
330
+
331
+ # Fallback: dump entire result
332
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
333
+
334
+ return [TextContent(type="text", text=str(result))]
335
+
336
+ return server
337
+
338
+
339
+ async def run_stdio_server() -> None:
340
+ """Run the MCP server on stdio."""
341
+ server = create_server()
342
+ async with stdio_server() as (read_stream, write_stream):
343
+ logger.info("stdio MCP server starting")
344
+ await server.run(read_stream, write_stream, server.create_initialization_options())