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.
- stackchan_mcp/__init__.py +7 -0
- stackchan_mcp/__main__.py +12 -0
- stackchan_mcp/audio_stream.py +34 -0
- stackchan_mcp/capture_server.py +91 -0
- stackchan_mcp/cli.py +57 -0
- stackchan_mcp/esp32_client.py +340 -0
- stackchan_mcp/gateway.py +123 -0
- stackchan_mcp/handlers/__init__.py +7 -0
- stackchan_mcp/handlers/audio.py +21 -0
- stackchan_mcp/handlers/camera.py +25 -0
- stackchan_mcp/handlers/robot.py +52 -0
- stackchan_mcp/mcp_router.py +126 -0
- stackchan_mcp/protocol.py +95 -0
- stackchan_mcp/server.py +28 -0
- stackchan_mcp/stdio_server.py +344 -0
- stackchan_mcp/tools.py +82 -0
- stackchan_mcp-0.1.0.dist-info/METADATA +238 -0
- stackchan_mcp-0.1.0.dist-info/RECORD +21 -0
- stackchan_mcp-0.1.0.dist-info/WHEEL +4 -0
- stackchan_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- stackchan_mcp-0.1.0.dist-info/licenses/LICENSE +39 -0
|
@@ -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
|
stackchan_mcp/server.py
ADDED
|
@@ -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())
|