stackchan-mcp 0.9.1__py3-none-win_amd64.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.
Files changed (43) hide show
  1. stackchan_mcp/__init__.py +81 -0
  2. stackchan_mcp/__main__.py +12 -0
  3. stackchan_mcp/_libs/SOURCES.md +130 -0
  4. stackchan_mcp/_libs/opus.dll +0 -0
  5. stackchan_mcp/audio_input_hook.py +432 -0
  6. stackchan_mcp/audio_stream.py +162 -0
  7. stackchan_mcp/capture_server.py +469 -0
  8. stackchan_mcp/cli.py +958 -0
  9. stackchan_mcp/esp32_client.py +983 -0
  10. stackchan_mcp/event_log.py +189 -0
  11. stackchan_mcp/gateway.py +274 -0
  12. stackchan_mcp/handlers/__init__.py +7 -0
  13. stackchan_mcp/handlers/audio.py +21 -0
  14. stackchan_mcp/handlers/camera.py +25 -0
  15. stackchan_mcp/handlers/robot.py +52 -0
  16. stackchan_mcp/http_server.py +398 -0
  17. stackchan_mcp/mcp_router.py +126 -0
  18. stackchan_mcp/mdns_advertiser.py +347 -0
  19. stackchan_mcp/notify.example.yml +21 -0
  20. stackchan_mcp/notify_config.py +235 -0
  21. stackchan_mcp/ownership.py +270 -0
  22. stackchan_mcp/protocol.py +95 -0
  23. stackchan_mcp/queue.py +191 -0
  24. stackchan_mcp/server.py +28 -0
  25. stackchan_mcp/stdio_server.py +1365 -0
  26. stackchan_mcp/stt/__init__.py +62 -0
  27. stackchan_mcp/stt/audio_utils.py +102 -0
  28. stackchan_mcp/stt/base.py +94 -0
  29. stackchan_mcp/stt/faster_whisper.py +217 -0
  30. stackchan_mcp/stt/openai_whisper.py +177 -0
  31. stackchan_mcp/stt/orchestrator.py +568 -0
  32. stackchan_mcp/tools.py +82 -0
  33. stackchan_mcp/tts/__init__.py +62 -0
  34. stackchan_mcp/tts/audio_utils.py +177 -0
  35. stackchan_mcp/tts/base.py +86 -0
  36. stackchan_mcp/tts/orchestrator.py +688 -0
  37. stackchan_mcp/tts/voicevox.py +184 -0
  38. stackchan_mcp-0.9.1.dist-info/METADATA +324 -0
  39. stackchan_mcp-0.9.1.dist-info/RECORD +43 -0
  40. stackchan_mcp-0.9.1.dist-info/WHEEL +5 -0
  41. stackchan_mcp-0.9.1.dist-info/entry_points.txt +2 -0
  42. stackchan_mcp-0.9.1.dist-info/licenses/LICENSE +39 -0
  43. stackchan_mcp-0.9.1.dist-info/licenses/LICENSE-THIRD-PARTY +65 -0
@@ -0,0 +1,1365 @@
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
+ from contextlib import AsyncExitStack
10
+ import inspect
11
+ import json
12
+ import logging
13
+ from typing import Any, Literal, cast
14
+
15
+ import anyio
16
+ from mcp.server import InitializationOptions, NotificationOptions, Server
17
+ from mcp.server.session import ServerSession
18
+ from mcp.server.stdio import stdio_server
19
+ from mcp.types import Notification, TextContent, Tool
20
+
21
+ from . import __version__
22
+ from .gateway import get_gateway
23
+ from .notify_config import NotifyConfig, load_notify_config
24
+ from .stt import listen_and_transcribe
25
+ from .tts import synthesize_and_send
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ STACKCHAN_EVENT_METHOD = "stackchan/event"
30
+ CHANNEL_NOTIFICATION_METHOD = "notifications/claude/channel"
31
+ CHANNEL_CAPABILITY = "claude/channel"
32
+ _SUPPORTED_EVENT_METHODS = {STACKCHAN_EVENT_METHOD, CHANNEL_NOTIFICATION_METHOD}
33
+ STACKCHAN_EVENT_INSTRUCTIONS = (
34
+ "Stack-chan physical events arrive as server-initiated "
35
+ "notifications with method='stackchan/event'. Params include "
36
+ "event_type ('touch'), subtype ('tap' or 'stroke'), "
37
+ "duration_ms, ts, session_id. When such a notification "
38
+ "arrives, react naturally using existing tools "
39
+ "(set_avatar, say, set_mouth, set_leds, move_head). There is "
40
+ "no dedicated reply tool — the existing tool palette is the "
41
+ "reaction surface."
42
+ )
43
+ STACKCHAN_CHANNEL_INSTRUCTIONS = (
44
+ 'Stack-chan physical events arrive as Channels notifications under '
45
+ '<channel source="plugin:stackchanmcp:stackchanmcp" action="..." '
46
+ 'subtype="..." duration_ms="...">. React naturally using existing '
47
+ 'tools (set_avatar, say, set_mouth, set_leds, move_head).'
48
+ )
49
+ STACKCHAN_JSONL_INSTRUCTIONS = (
50
+ "Stack-chan physical events are persisted to the JSONL log; host "
51
+ "integration consumes them externally."
52
+ )
53
+
54
+ PRESET_DPS = {
55
+ "low": 30,
56
+ "mid": 120,
57
+ "high": 240,
58
+ }
59
+ SPEED_DPS_MAX = 10000
60
+ SPEED_DESCRIPTION = """speed (optional): How fast to move the head.
61
+ - "low" — slow, deliberate, ~30°/s. Good for curious tilts or gentle look-toward.
62
+ - "mid" — default natural turn, ~120°/s. Use for conversational eye contact.
63
+ - "high" — quick reaction, ~240°/s. Use for surprise / double-take.
64
+ - Or a raw degrees-per-second integer if you need a specific value."""
65
+
66
+ _active_session: Any | None = None
67
+ _active_sessions: dict[int, Any] = {}
68
+
69
+
70
+ class StackChanEventNotification(
71
+ Notification[dict[str, Any], Literal["stackchan/event"]]
72
+ ):
73
+ method: Literal["stackchan/event"] = "stackchan/event"
74
+ params: dict[str, Any]
75
+
76
+
77
+ class StackChanChannelNotification(
78
+ Notification[dict[str, Any], Literal["notifications/claude/channel"]]
79
+ ):
80
+ method: Literal["notifications/claude/channel"] = "notifications/claude/channel"
81
+ params: dict[str, Any]
82
+
83
+
84
+ class StackChanServer(Server):
85
+ def __init__(self, name: str, *, notify_config: NotifyConfig) -> None:
86
+ super().__init__(name)
87
+ self._notify_config = notify_config
88
+
89
+ def create_initialization_options(
90
+ self,
91
+ notification_options: NotificationOptions | None = None,
92
+ experimental_capabilities: dict[str, dict[str, Any]] | None = None,
93
+ ) -> InitializationOptions:
94
+ if notification_options is None and experimental_capabilities is None:
95
+ return _create_initialization_options(self, self._notify_config)
96
+ return super().create_initialization_options(
97
+ notification_options=notification_options,
98
+ experimental_capabilities=experimental_capabilities,
99
+ )
100
+
101
+ async def run(
102
+ self,
103
+ read_stream: Any,
104
+ write_stream: Any,
105
+ initialization_options: InitializationOptions,
106
+ raise_exceptions: bool = False,
107
+ stateless: bool = False,
108
+ ) -> None:
109
+ global _active_session
110
+ session: Any | None = None
111
+ try:
112
+ async with AsyncExitStack() as stack:
113
+ lifespan_context = await stack.enter_async_context(self.lifespan(self))
114
+ session = await stack.enter_async_context(
115
+ ServerSession(
116
+ read_stream,
117
+ write_stream,
118
+ initialization_options,
119
+ stateless=stateless,
120
+ )
121
+ )
122
+ _active_session = session
123
+ _active_sessions[id(session)] = session
124
+
125
+ task_support = (
126
+ self._experimental_handlers.task_support
127
+ if self._experimental_handlers
128
+ else None
129
+ )
130
+ if task_support is not None:
131
+ task_support.configure_session(session)
132
+ await stack.enter_async_context(task_support.run())
133
+
134
+ async with anyio.create_task_group() as tg:
135
+ try:
136
+ async for message in session.incoming_messages:
137
+ logger.debug("Received message: %s", message)
138
+ tg.start_soon(
139
+ self._handle_message,
140
+ message,
141
+ session,
142
+ lifespan_context,
143
+ raise_exceptions,
144
+ )
145
+ finally:
146
+ tg.cancel_scope.cancel()
147
+ finally:
148
+ if session is not None:
149
+ _active_sessions.pop(id(session), None)
150
+ _active_session = _latest_active_session()
151
+
152
+ async def _handle_message(
153
+ self,
154
+ message: Any,
155
+ session: Any,
156
+ lifespan_context: Any,
157
+ raise_exceptions: bool = False,
158
+ ) -> None:
159
+ global _active_session
160
+ _active_session = session
161
+ _active_sessions[id(session)] = session
162
+ await super()._handle_message(
163
+ message,
164
+ session,
165
+ lifespan_context,
166
+ raise_exceptions,
167
+ )
168
+
169
+
170
+ def _latest_active_session() -> Any | None:
171
+ if not _active_sessions:
172
+ return None
173
+ return next(reversed(_active_sessions.values()))
174
+
175
+
176
+ async def notify_stackchan_event(method: str, params: dict[str, Any]) -> None:
177
+ """Forward a stackchan event to the connected MCP client."""
178
+ if method not in _SUPPORTED_EVENT_METHODS:
179
+ logger.warning("Unsupported stackchan event notification method: %s", method)
180
+ return
181
+
182
+ sessions = list(_active_sessions.values())
183
+ if not sessions and _active_session is not None:
184
+ sessions = [_active_session]
185
+ if not sessions:
186
+ logger.warning("Cannot emit %s notification: no active MCP session", method)
187
+ return
188
+
189
+ notification = _build_stackchan_notification(method, params)
190
+ for session in sessions:
191
+ try:
192
+ await session.send_notification(cast(Any, notification))
193
+ except Exception as exc: # pragma: no cover - depends on client transport failure
194
+ logger.warning("Failed to emit %s notification: %s", method, exc)
195
+
196
+
197
+ def _build_stackchan_notification(
198
+ method: str,
199
+ params: dict[str, Any],
200
+ ) -> StackChanEventNotification | StackChanChannelNotification:
201
+ if method == STACKCHAN_EVENT_METHOD:
202
+ return StackChanEventNotification(params=params)
203
+ return StackChanChannelNotification(params=params)
204
+
205
+
206
+ def _build_experimental_capabilities(
207
+ notify_config: NotifyConfig,
208
+ ) -> dict[str, dict[str, Any]]:
209
+ capabilities: dict[str, dict[str, Any]] = {}
210
+ if notify_config.legacy_event_enabled:
211
+ capabilities[STACKCHAN_EVENT_METHOD] = {}
212
+ if notify_config.channels_enabled:
213
+ capabilities[CHANNEL_CAPABILITY] = {}
214
+ return capabilities
215
+
216
+
217
+ def _build_stackchan_event_instructions(notify_config: NotifyConfig) -> str | None:
218
+ fragments = []
219
+ if notify_config.channels_enabled:
220
+ fragments.append(STACKCHAN_CHANNEL_INSTRUCTIONS)
221
+ if notify_config.legacy_event_enabled:
222
+ fragments.append(STACKCHAN_EVENT_INSTRUCTIONS)
223
+ if (
224
+ notify_config.jsonl_enabled
225
+ and not notify_config.channels_enabled
226
+ and not notify_config.legacy_event_enabled
227
+ ):
228
+ fragments.append(STACKCHAN_JSONL_INSTRUCTIONS)
229
+ if not fragments:
230
+ return None
231
+ return "\n\n".join(fragments)
232
+
233
+
234
+ def _create_initialization_options(
235
+ server: Server,
236
+ notify_config: NotifyConfig,
237
+ ) -> InitializationOptions:
238
+ return InitializationOptions(
239
+ server_name="stackchanmcp",
240
+ server_version=__version__,
241
+ capabilities=server.get_capabilities(
242
+ notification_options=NotificationOptions(),
243
+ experimental_capabilities=_build_experimental_capabilities(notify_config),
244
+ ),
245
+ instructions=_build_stackchan_event_instructions(notify_config),
246
+ )
247
+
248
+
249
+ def _verify_mcp_sdk_compatibility() -> None:
250
+ """Fail fast if the installed MCP SDK no longer exposes the private
251
+ attributes that ``StackChanServer`` depends on.
252
+
253
+ ``StackChanServer`` mirrors a slimmed-down copy of ``Server.run()`` so it
254
+ can capture the active ``ServerSession`` for server-initiated
255
+ ``stackchan/event`` notifications. The public MCP SDK currently does not
256
+ offer a stable hook for this, so the subclass touches
257
+ ``Server._experimental_handlers`` and ``Server._handle_message`` directly.
258
+
259
+ These private members are pinned by the ``mcp>=1.27,<2.0`` range declared
260
+ in ``pyproject.toml``. This guard adds an extra safety net so the gateway
261
+ fails with a clear ``RuntimeError`` at startup rather than silently
262
+ dropping notifications or crashing mid-message if a future installation
263
+ somehow resolves a wholly incompatible SDK shape.
264
+ """
265
+
266
+ probe = Server("compat-check")
267
+
268
+ if not hasattr(probe, "_experimental_handlers"):
269
+ raise RuntimeError(
270
+ "stackchan-mcp gateway requires `mcp.server.Server._experimental_handlers` "
271
+ "to exist on instances. The installed MCP SDK appears to have removed or "
272
+ "renamed this attribute; pin `mcp` to a verified 1.x release."
273
+ )
274
+
275
+ handle = getattr(probe, "_handle_message", None)
276
+ if not callable(handle) or not inspect.iscoroutinefunction(handle):
277
+ raise RuntimeError(
278
+ "stackchan-mcp gateway requires `mcp.server.Server._handle_message` to be "
279
+ "an async callable. The installed MCP SDK does not expose it in the "
280
+ "expected shape; pin `mcp` to a verified 1.x release."
281
+ )
282
+
283
+ try:
284
+ sig = inspect.signature(handle)
285
+ except (TypeError, ValueError) as exc:
286
+ raise RuntimeError(
287
+ "stackchan-mcp gateway could not introspect "
288
+ "`mcp.server.Server._handle_message` signature on the installed MCP SDK; "
289
+ "pin `mcp` to a verified 1.x release."
290
+ ) from exc
291
+
292
+ positional = [
293
+ p
294
+ for p in sig.parameters.values()
295
+ if p.kind
296
+ in (
297
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
298
+ inspect.Parameter.POSITIONAL_ONLY,
299
+ inspect.Parameter.VAR_POSITIONAL,
300
+ )
301
+ ]
302
+ if len(positional) < 4:
303
+ raise RuntimeError(
304
+ "stackchan-mcp gateway requires `mcp.server.Server._handle_message` to "
305
+ "accept at least 4 positional arguments "
306
+ "(message, session, lifespan_context, raise_exceptions); the installed "
307
+ f"MCP SDK exposes {sig}. Pin `mcp` to a verified 1.x release."
308
+ )
309
+
310
+
311
+ def _resolve_speed_dps(speed: Any) -> int | None:
312
+ """Return an int speed_dps to forward, or None to omit the field."""
313
+ if speed is None:
314
+ return None
315
+ if isinstance(speed, bool):
316
+ raise TypeError("speed must be a preset string or an integer, not bool")
317
+ if isinstance(speed, str):
318
+ if speed not in PRESET_DPS:
319
+ raise ValueError(
320
+ f"speed preset must be one of {list(PRESET_DPS)}, got {speed!r}"
321
+ )
322
+ return PRESET_DPS[speed]
323
+ if isinstance(speed, int):
324
+ if speed < 1:
325
+ raise ValueError(f"speed integer must be >= 1, got {speed}")
326
+ if speed > SPEED_DPS_MAX:
327
+ raise ValueError(f"speed integer must be <= {SPEED_DPS_MAX}, got {speed}")
328
+ return speed
329
+ raise TypeError(
330
+ f"speed must be 'low' / 'mid' / 'high' / int / None, got {type(speed).__name__}"
331
+ )
332
+
333
+
334
+ async def _dispatch_mcp_tool(
335
+ name: str,
336
+ arguments: dict[str, Any],
337
+ gateway: Any,
338
+ ) -> list[TextContent]:
339
+ """Run one StackChan MCP tool against the provided gateway instance."""
340
+ if name == "get_status":
341
+ status = gateway.esp32.get_status()
342
+ return [TextContent(type="text", text=json.dumps(status, indent=2))]
343
+
344
+ if name == "say":
345
+ try:
346
+ result = await synthesize_and_send(arguments, gateway=gateway)
347
+ except (ValueError, NotImplementedError, RuntimeError) as exc:
348
+ return [
349
+ TextContent(
350
+ type="text",
351
+ text=json.dumps({"error": str(exc)}),
352
+ )
353
+ ]
354
+ return [TextContent(type="text", text=json.dumps(result))]
355
+
356
+ if name == "listen":
357
+ try:
358
+ result = await listen_and_transcribe(arguments, gateway=gateway)
359
+ except (ValueError, NotImplementedError, RuntimeError) as exc:
360
+ return [
361
+ TextContent(
362
+ type="text",
363
+ text=json.dumps({"error": str(exc)}),
364
+ )
365
+ ]
366
+ return [TextContent(type="text", text=json.dumps(result))]
367
+
368
+ if name == "load_avatar_set":
369
+ archive_path = arguments.get("archive_path", "")
370
+ mode = arguments.get("mode", "")
371
+ try:
372
+ timeout = float(arguments.get("timeout", 60.0))
373
+ except (TypeError, ValueError):
374
+ timeout = 60.0
375
+ if not archive_path or not isinstance(archive_path, str):
376
+ return [
377
+ TextContent(
378
+ type="text",
379
+ text=json.dumps(
380
+ {"ok": False, "error": "archive_path is required"}
381
+ ),
382
+ )
383
+ ]
384
+ if mode not in ("layered", "matrix"):
385
+ return [
386
+ TextContent(
387
+ type="text",
388
+ text=json.dumps({"ok": False, "error": f"unknown mode: {mode}"}),
389
+ )
390
+ ]
391
+ result = await gateway.load_avatar_set(archive_path, mode, timeout)
392
+ return [TextContent(type="text", text=json.dumps(result))]
393
+
394
+ if not gateway.esp32.device_connected:
395
+ return [
396
+ TextContent(
397
+ type="text",
398
+ text=json.dumps(
399
+ {"error": "No ESP32 device connected. Please check the device."}
400
+ ),
401
+ )
402
+ ]
403
+
404
+ if name == "move_head":
405
+ yaw_val = arguments.get("yaw")
406
+ pitch_val = arguments.get("pitch")
407
+ if (
408
+ not isinstance(yaw_val, int)
409
+ or isinstance(yaw_val, bool)
410
+ or not (-90 <= yaw_val <= 90)
411
+ ):
412
+ return [
413
+ TextContent(
414
+ type="text",
415
+ text=json.dumps(
416
+ {
417
+ "error": (
418
+ "yaw must be an integer in -90..90 "
419
+ f"(got {yaw_val!r})"
420
+ )
421
+ }
422
+ ),
423
+ )
424
+ ]
425
+ if (
426
+ not isinstance(pitch_val, int)
427
+ or isinstance(pitch_val, bool)
428
+ or not (5 <= pitch_val <= 85)
429
+ ):
430
+ return [
431
+ TextContent(
432
+ type="text",
433
+ text=json.dumps(
434
+ {
435
+ "error": (
436
+ "pitch must be an integer in 5..85 "
437
+ "(M5Stack-recommended operating range; "
438
+ "for the wider firmware hard clamp "
439
+ "0..88 use `set_head_angles`). got "
440
+ f"{pitch_val!r}"
441
+ )
442
+ }
443
+ ),
444
+ )
445
+ ]
446
+ try:
447
+ speed_dps = _resolve_speed_dps(arguments.get("speed"))
448
+ except (TypeError, ValueError) as exc:
449
+ return [
450
+ TextContent(
451
+ type="text",
452
+ text=json.dumps({"error": str(exc)}),
453
+ )
454
+ ]
455
+ arguments = {"yaw": yaw_val, "pitch": pitch_val}
456
+ if speed_dps is not None:
457
+ arguments["speed_dps"] = speed_dps
458
+
459
+ tool_map: dict[str, tuple[str, dict[str, Any]]] = {
460
+ "get_device_info": (
461
+ "self.get_device_status",
462
+ {},
463
+ ),
464
+ "take_photo": (
465
+ "self.camera.take_photo",
466
+ arguments,
467
+ ),
468
+ "set_volume": (
469
+ "self.audio_speaker.set_volume",
470
+ arguments,
471
+ ),
472
+ "set_brightness": (
473
+ "self.screen.set_brightness",
474
+ arguments,
475
+ ),
476
+ "move_head": (
477
+ "self.robot.set_head_angles",
478
+ arguments,
479
+ ),
480
+ "get_head_angles": (
481
+ "self.robot.get_head_angles",
482
+ {},
483
+ ),
484
+ "gpio_test": (
485
+ "self.robot.gpio_test",
486
+ {},
487
+ ),
488
+ "uart_diag": (
489
+ "self.robot.uart_diag",
490
+ {},
491
+ ),
492
+ "check_vm_en": (
493
+ "self.robot.check_vm_en",
494
+ {},
495
+ ),
496
+ "set_avatar": (
497
+ "self.display.set_avatar",
498
+ arguments,
499
+ ),
500
+ "set_mouth": (
501
+ "self.display.set_mouth",
502
+ arguments,
503
+ ),
504
+ "set_mouth_sequence": (
505
+ "self.display.set_mouth_sequence",
506
+ {"steps_json": json.dumps(arguments.get("steps", []))},
507
+ ),
508
+ "set_blink": (
509
+ "self.display.set_blink",
510
+ arguments,
511
+ ),
512
+ "set_servo_torque": (
513
+ "self.robot.set_servo_torque",
514
+ arguments,
515
+ ),
516
+ "set_auto_torque_release": (
517
+ "self.robot.set_auto_torque_release",
518
+ arguments,
519
+ ),
520
+ "get_touch_state": (
521
+ "self.touch.get_touch_state",
522
+ {},
523
+ ),
524
+ "set_led": (
525
+ "self.led.set_color",
526
+ arguments,
527
+ ),
528
+ "set_all_leds": (
529
+ "self.led.set_all",
530
+ arguments,
531
+ ),
532
+ "set_leds": (
533
+ "self.led.set_many",
534
+ {"colors": json.dumps(arguments.get("colors", []))},
535
+ ),
536
+ "clear_leds": (
537
+ "self.led.clear",
538
+ {},
539
+ ),
540
+ "i2c_scan": (
541
+ "self.i2c.scan",
542
+ {},
543
+ ),
544
+ "i2c_read": (
545
+ "self.i2c.read",
546
+ arguments,
547
+ ),
548
+ "i2c_write": (
549
+ "self.i2c.write",
550
+ arguments,
551
+ ),
552
+ "i2c_write_read": (
553
+ "self.i2c.write_read",
554
+ arguments,
555
+ ),
556
+ }
557
+
558
+ if name not in tool_map:
559
+ return [
560
+ TextContent(
561
+ type="text",
562
+ text=json.dumps({"error": f"Unknown tool: {name}"}),
563
+ )
564
+ ]
565
+
566
+ esp32_name, esp32_args = tool_map[name]
567
+ result, error = await gateway.esp32.call_tool(esp32_name, esp32_args)
568
+
569
+ if error:
570
+ return [
571
+ TextContent(
572
+ type="text",
573
+ text=json.dumps({"error": error.get("message", str(error))}),
574
+ )
575
+ ]
576
+
577
+ if isinstance(result, dict):
578
+ content = result.get("content", [])
579
+ if content and isinstance(content, list):
580
+ texts = []
581
+ for item in content:
582
+ if isinstance(item, dict) and item.get("type") == "text":
583
+ texts.append(item.get("text", ""))
584
+ if texts:
585
+ return [TextContent(type="text", text="\n".join(texts))]
586
+
587
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
588
+
589
+ return [TextContent(type="text", text=str(result))]
590
+
591
+
592
+ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
593
+ """Create and configure the MCP server with tool handlers."""
594
+ _verify_mcp_sdk_compatibility()
595
+ if notify_config is None:
596
+ notify_config = load_notify_config()
597
+ server = StackChanServer("stackchanmcp", notify_config=notify_config)
598
+
599
+ @server.list_tools()
600
+ async def list_tools() -> list[Tool]:
601
+ """List available stackchan tools.
602
+
603
+ Tools prefixed with ESP32 names (self.*) are relayed to the device.
604
+ get_status is handled locally by the gateway.
605
+ """
606
+ return [
607
+ Tool(
608
+ name="get_status",
609
+ description=(
610
+ "Get the gateway's connection status: whether ESP32 is connected, "
611
+ "device info, and list of available device tools."
612
+ ),
613
+ inputSchema={
614
+ "type": "object",
615
+ "properties": {},
616
+ },
617
+ ),
618
+ Tool(
619
+ name="get_device_info",
620
+ description=(
621
+ "Get real-time device information from ESP32: "
622
+ "battery level, speaker volume, screen brightness, network status, etc."
623
+ ),
624
+ inputSchema={
625
+ "type": "object",
626
+ "properties": {},
627
+ },
628
+ ),
629
+ Tool(
630
+ name="take_photo",
631
+ description=(
632
+ "Take a photo with the robot's camera and ask a question about it. "
633
+ "The device captures an image and returns an AI-generated description."
634
+ ),
635
+ inputSchema={
636
+ "type": "object",
637
+ "properties": {
638
+ "question": {
639
+ "type": "string",
640
+ "description": "Question to ask about the photo (e.g. 'What do you see?')",
641
+ },
642
+ },
643
+ "required": ["question"],
644
+ },
645
+ ),
646
+ Tool(
647
+ name="set_volume",
648
+ description="Set the speaker volume (0-100).",
649
+ inputSchema={
650
+ "type": "object",
651
+ "properties": {
652
+ "volume": {
653
+ "type": "integer",
654
+ "description": "Volume level (0-100)",
655
+ },
656
+ },
657
+ "required": ["volume"],
658
+ },
659
+ ),
660
+ Tool(
661
+ name="set_brightness",
662
+ description="Set the screen brightness (0-100).",
663
+ inputSchema={
664
+ "type": "object",
665
+ "properties": {
666
+ "brightness": {
667
+ "type": "integer",
668
+ "description": "Brightness level (0-100)",
669
+ },
670
+ },
671
+ "required": ["brightness"],
672
+ },
673
+ ),
674
+ Tool(
675
+ name="move_head",
676
+ description=(
677
+ "Move the robot's head to safe, recommended angles. "
678
+ "yaw: horizontal (-90 to 90), pitch: vertical (5 to 85, "
679
+ "the M5Stack-recommended operating range). Out-of-range "
680
+ "requests are rejected at this MCP layer; for advanced "
681
+ "callers that need the firmware hard clamp (pitch 0..88), "
682
+ "use the firmware-side `set_head_angles` device tool, "
683
+ "which exposes a permissive schema and the authoritative "
684
+ "two-tier guard described in the README."
685
+ ),
686
+ inputSchema={
687
+ "type": "object",
688
+ "properties": {
689
+ "yaw": {
690
+ "type": "integer",
691
+ "description": "Horizontal angle in degrees (-90 to 90)",
692
+ "minimum": -90,
693
+ "maximum": 90,
694
+ },
695
+ "pitch": {
696
+ "type": "integer",
697
+ "description": (
698
+ "Vertical angle in degrees (5 to 85, "
699
+ "M5Stack-recommended operating range). For the "
700
+ "wider firmware hard clamp (0..88), use the "
701
+ "`set_head_angles` device tool instead."
702
+ ),
703
+ "minimum": 5,
704
+ "maximum": 85,
705
+ },
706
+ "speed": {
707
+ "oneOf": [
708
+ {"enum": ["low", "mid", "high"]},
709
+ {
710
+ "type": "integer",
711
+ "minimum": 1,
712
+ "maximum": SPEED_DPS_MAX,
713
+ },
714
+ ],
715
+ "description": SPEED_DESCRIPTION,
716
+ },
717
+ },
718
+ "required": ["yaw", "pitch"],
719
+ },
720
+ ),
721
+ Tool(
722
+ name="get_head_angles",
723
+ description="Get the robot's current head angles: yaw and pitch in degrees.",
724
+ inputSchema={
725
+ "type": "object",
726
+ "properties": {},
727
+ },
728
+ ),
729
+ Tool(
730
+ name="gpio_test",
731
+ description="Test GPIO6 pin by toggling HIGH/LOW 5 times. Check if servo reacts.",
732
+ inputSchema={"type": "object", "properties": {}},
733
+ ),
734
+ Tool(
735
+ name="uart_diag",
736
+ description="Send raw servo bytes via UART and report write result.",
737
+ inputSchema={"type": "object", "properties": {}},
738
+ ),
739
+ Tool(
740
+ name="check_vm_en",
741
+ description=(
742
+ "Diagnostic: read PY32 REG_GPIO_O_L and report whether VM EN "
743
+ "(pin 0 = servo power) is currently HIGH. Returns "
744
+ "{io_expander_present, i2c_read_ok, raw, vm_en_high}."
745
+ ),
746
+ inputSchema={"type": "object", "properties": {}},
747
+ ),
748
+ Tool(
749
+ name="set_avatar",
750
+ description=(
751
+ "Switch the avatar face shown on the LCD. "
752
+ "Choose one of the supported faces; this is the robot's "
753
+ "actual visible expression, not just a label. "
754
+ "Pass 'off' to hide the avatar and disable blink, exposing the "
755
+ "underlying xiaozhi-esp32 screens (WiFi config UI, OTA, settings); "
756
+ "any other face brings the avatar back and restores blink."
757
+ ),
758
+ inputSchema={
759
+ "type": "object",
760
+ "properties": {
761
+ "face": {
762
+ "type": "string",
763
+ "enum": [
764
+ "idle",
765
+ "happy",
766
+ "thinking",
767
+ "sad",
768
+ "surprised",
769
+ "embarrassed",
770
+ "off",
771
+ ],
772
+ "description": (
773
+ "One of: idle, happy, thinking, sad, surprised, "
774
+ "embarrassed, off."
775
+ ),
776
+ },
777
+ },
778
+ "required": ["face"],
779
+ },
780
+ ),
781
+ Tool(
782
+ name="set_mouth",
783
+ description=(
784
+ "Set the avatar mouth shape for lip-sync. "
785
+ "The shape is held until the next set_avatar / set_mouth call, "
786
+ "or until an autonomous blink restores the resting face. "
787
+ "Calling this while a set_mouth_sequence is in flight "
788
+ "interrupts the sequence."
789
+ ),
790
+ inputSchema={
791
+ "type": "object",
792
+ "properties": {
793
+ "mouth": {
794
+ "type": "string",
795
+ "enum": ["closed", "half", "open", "e", "u"],
796
+ "description": "One of: closed, half, open, e, u.",
797
+ },
798
+ },
799
+ "required": ["mouth"],
800
+ },
801
+ ),
802
+ Tool(
803
+ name="set_mouth_sequence",
804
+ description=(
805
+ "Queue a lip-sync sequence and play it on the device. "
806
+ "Each step holds 'shape' for 'duration_ms' before "
807
+ "advancing. The firmware walks the queue locally so "
808
+ "there is no per-step network RTT (use this instead of "
809
+ "issuing many set_mouth calls back-to-back from a TTS "
810
+ "loop). Returns immediately with the queued step count "
811
+ "and estimated total duration. Calling set_mouth, "
812
+ "set_avatar, or this tool again interrupts the in-flight "
813
+ "sequence and replaces it. Autonomous blink is paused "
814
+ "while a sequence is playing and resumed when it ends. "
815
+ "The final shape is held until the next "
816
+ "set_mouth / set_avatar call, or until an autonomous "
817
+ "blink restores the resting face — this is the same "
818
+ "Phase 2 trade-off that applies to set_mouth, since the "
819
+ "blink animation ends by repainting the full face. If "
820
+ "the final shape must persist visually, disable blink "
821
+ "with set_blink(false) before the sequence (or append a "
822
+ "closed step if you just want the mouth to close at "
823
+ "the end)."
824
+ ),
825
+ inputSchema={
826
+ "type": "object",
827
+ "properties": {
828
+ "steps": {
829
+ "type": "array",
830
+ "minItems": 1,
831
+ "maxItems": 256,
832
+ "items": {
833
+ "type": "object",
834
+ "properties": {
835
+ "shape": {
836
+ "type": "string",
837
+ "enum": ["closed", "half", "open", "e", "u"],
838
+ "description": (
839
+ "Mouth shape for this step. "
840
+ "One of: closed, half, open, e, u."
841
+ ),
842
+ },
843
+ "duration_ms": {
844
+ "type": "integer",
845
+ "minimum": 10,
846
+ "maximum": 10000,
847
+ "description": (
848
+ "How long to hold this shape "
849
+ "before advancing, in ms (10..10000)."
850
+ ),
851
+ },
852
+ },
853
+ "required": ["shape", "duration_ms"],
854
+ },
855
+ "description": (
856
+ "Ordered list of mouth shapes with hold "
857
+ "durations (1..256 steps)."
858
+ ),
859
+ },
860
+ },
861
+ "required": ["steps"],
862
+ },
863
+ ),
864
+ Tool(
865
+ name="set_blink",
866
+ description=(
867
+ "Enable or disable autonomous eye blinking. "
868
+ "When enabled, the avatar blinks every 3-6 seconds at random."
869
+ ),
870
+ inputSchema={
871
+ "type": "object",
872
+ "properties": {
873
+ "enabled": {
874
+ "type": "boolean",
875
+ "description": "True to start blinking, false to stop.",
876
+ },
877
+ },
878
+ "required": ["enabled"],
879
+ },
880
+ ),
881
+ Tool(
882
+ name="set_servo_torque",
883
+ description=(
884
+ "Enable or disable SCS0009 servo torque on the yaw / "
885
+ "pitch axes independently. Disabling torque stops motor "
886
+ "current on that axis; the head holds via static "
887
+ "friction (no motion is commanded). On disable, the "
888
+ "firmware also cancels any in-flight MotionDriver "
889
+ "interpolation and marks the axis position unknown so "
890
+ "a subsequent same-target set_head_angles is re-"
891
+ "dispatched rather than no-op-optimized. Re-enabling "
892
+ "torque does NOT trigger a move; the next "
893
+ "set_head_angles or wobble call will. Diagnostic / "
894
+ "power-management primitive used to observe physical "
895
+ "head behavior under torque-off (Issue #163; auto "
896
+ "release on idle is Issue #152 Phase 4)."
897
+ ),
898
+ inputSchema={
899
+ "type": "object",
900
+ "properties": {
901
+ "yaw_enabled": {
902
+ "type": "boolean",
903
+ "description": (
904
+ "True to enable yaw axis torque, false to "
905
+ "disable."
906
+ ),
907
+ },
908
+ "pitch_enabled": {
909
+ "type": "boolean",
910
+ "description": (
911
+ "True to enable pitch axis torque, false "
912
+ "to disable."
913
+ ),
914
+ },
915
+ },
916
+ "required": ["yaw_enabled", "pitch_enabled"],
917
+ },
918
+ ),
919
+ Tool(
920
+ name="set_auto_torque_release",
921
+ description=(
922
+ "Enable or disable firmware-side automatic SCS0009 "
923
+ "torque release after motion idle timeout. timeout_ms "
924
+ "is clamped by the firmware to 500..600000 ms. "
925
+ "Disabling this setting does not re-enable torque if "
926
+ "it is already released; the next set_head_angles, "
927
+ "wobble, or explicit set_servo_torque(true, true) call "
928
+ "re-engages torque."
929
+ ),
930
+ inputSchema={
931
+ "type": "object",
932
+ "properties": {
933
+ "enabled": {
934
+ "type": "boolean",
935
+ "description": (
936
+ "True to enable idle auto-release, false "
937
+ "to disable it."
938
+ ),
939
+ },
940
+ "timeout_ms": {
941
+ "type": "integer",
942
+ "description": (
943
+ "Idle timeout in milliseconds. Values "
944
+ "outside 500..600000 are clamped by the "
945
+ "firmware handler."
946
+ ),
947
+ },
948
+ },
949
+ "required": ["enabled", "timeout_ms"],
950
+ },
951
+ ),
952
+ Tool(
953
+ name="get_touch_state",
954
+ description=(
955
+ "Read the head-touch (Si12T) sensor state and the most recent "
956
+ "gesture event (tap/stroke/idle). Returns per-zone booleans, "
957
+ "the raw output byte, and how long ago the last event fired."
958
+ ),
959
+ inputSchema={
960
+ "type": "object",
961
+ "properties": {},
962
+ },
963
+ ),
964
+ Tool(
965
+ name="set_led",
966
+ description=(
967
+ "Set a single RGB LED on the StackChan base. There are 12 LEDs "
968
+ "arranged in two rows of 6 (index 0..11). Updates immediately."
969
+ ),
970
+ inputSchema={
971
+ "type": "object",
972
+ "properties": {
973
+ "index": {
974
+ "type": "integer",
975
+ "description": "LED index (0..11)",
976
+ "minimum": 0,
977
+ "maximum": 11,
978
+ },
979
+ "r": {"type": "integer", "description": "Red 0..255", "minimum": 0, "maximum": 255},
980
+ "g": {"type": "integer", "description": "Green 0..255", "minimum": 0, "maximum": 255},
981
+ "b": {"type": "integer", "description": "Blue 0..255", "minimum": 0, "maximum": 255},
982
+ },
983
+ "required": ["index", "r", "g", "b"],
984
+ },
985
+ ),
986
+ Tool(
987
+ name="set_all_leds",
988
+ description="Set all 12 RGB LEDs on the StackChan base to the same color. Updates immediately.",
989
+ inputSchema={
990
+ "type": "object",
991
+ "properties": {
992
+ "r": {"type": "integer", "description": "Red 0..255", "minimum": 0, "maximum": 255},
993
+ "g": {"type": "integer", "description": "Green 0..255", "minimum": 0, "maximum": 255},
994
+ "b": {"type": "integer", "description": "Blue 0..255", "minimum": 0, "maximum": 255},
995
+ },
996
+ "required": ["r", "g", "b"],
997
+ },
998
+ ),
999
+ Tool(
1000
+ name="set_leds",
1001
+ description=(
1002
+ "Set multiple RGB LEDs in one shot. 'colors' is an array of "
1003
+ "[r,g,b] triples starting at index 0 (e.g. [[255,0,0],[0,255,0]]). "
1004
+ "Up to 12 entries; extras are ignored, missing entries keep their "
1005
+ "previous color. Use this for animations / patterns to avoid 12x "
1006
+ "I2C round-trips."
1007
+ ),
1008
+ inputSchema={
1009
+ "type": "object",
1010
+ "properties": {
1011
+ "colors": {
1012
+ "type": "array",
1013
+ "description": "Array of [r,g,b] triples, each 0..255",
1014
+ "items": {
1015
+ "type": "array",
1016
+ "items": {"type": "integer", "minimum": 0, "maximum": 255},
1017
+ "minItems": 3,
1018
+ "maxItems": 3,
1019
+ },
1020
+ "minItems": 1,
1021
+ "maxItems": 12,
1022
+ },
1023
+ },
1024
+ "required": ["colors"],
1025
+ },
1026
+ ),
1027
+ Tool(
1028
+ name="clear_leds",
1029
+ description="Turn off all 12 RGB LEDs on the StackChan base.",
1030
+ inputSchema={"type": "object", "properties": {}},
1031
+ ),
1032
+ Tool(
1033
+ name="say",
1034
+ description=(
1035
+ "Speak the given text on the device speaker via gateway-side "
1036
+ "TTS (Phase 4, Issue #70). The gateway synthesises audio, "
1037
+ "encodes it to Opus, and pushes frames over the existing "
1038
+ "WebSocket — the device firmware does not change. Engine is "
1039
+ "selectable via 'voice' (default 'voicevox'). "
1040
+ "NOTE: this build ships the framework only; concrete engines "
1041
+ "(VOICEVOX, Irodori) land in follow-up PRs and require the "
1042
+ "matching optional extra (e.g. "
1043
+ "'pip install stackchan-mcp[tts-voicevox]'). Calling this tool "
1044
+ "before an engine is registered returns a clear error."
1045
+ ),
1046
+ inputSchema={
1047
+ "type": "object",
1048
+ "properties": {
1049
+ "text": {
1050
+ "type": "string",
1051
+ "description": "Text to speak. Must be non-empty.",
1052
+ },
1053
+ "voice": {
1054
+ "type": "string",
1055
+ "description": (
1056
+ "Engine identifier (e.g. 'voicevox', 'irodori'). "
1057
+ "Default 'voicevox'."
1058
+ ),
1059
+ "default": "voicevox",
1060
+ },
1061
+ "speaker_id": {
1062
+ "type": "integer",
1063
+ "description": (
1064
+ "Engine-specific speaker identifier "
1065
+ "(e.g. a VOICEVOX speaker ID)."
1066
+ ),
1067
+ },
1068
+ "reference_audio": {
1069
+ "type": "string",
1070
+ "description": (
1071
+ "Path to a reference audio file used by "
1072
+ "voice-cloning engines (e.g. Irodori). "
1073
+ "Ignored by engines that do not support it."
1074
+ ),
1075
+ },
1076
+ },
1077
+ "required": ["text"],
1078
+ },
1079
+ ),
1080
+ Tool(
1081
+ name="listen",
1082
+ description=(
1083
+ "Capture a short utterance from the device microphone and "
1084
+ "transcribe it via a gateway-side STT engine (Phase 4, "
1085
+ "Issue #91). The gateway sends a 'listen' notification "
1086
+ "over the existing WebSocket to put the device firmware "
1087
+ "into listening mode, buffers the Opus frames the device "
1088
+ "streams up during the capture window, then decodes and "
1089
+ "transcribes them once the window closes. Requires a "
1090
+ "minimal firmware change to handle the inbound 'listen' "
1091
+ "wire type (paired with this gateway release). Engine is "
1092
+ "selectable via 'engine' (default 'faster-whisper', local). "
1093
+ "Optional 'motion' feedback can switch the avatar to "
1094
+ "'thinking' during capture ('face-only') or tilt the head "
1095
+ "up while preserving yaw ('look-up'). "
1096
+ "Install the relevant extra "
1097
+ "('pip install stackchan-mcp[stt-faster-whisper]' or "
1098
+ "'stt-openai'); calling this tool before an engine is "
1099
+ "registered returns a clear error."
1100
+ ),
1101
+ inputSchema={
1102
+ "type": "object",
1103
+ "properties": {
1104
+ "duration_ms": {
1105
+ "type": "integer",
1106
+ "description": (
1107
+ "Capture window in milliseconds. Clamped to "
1108
+ "[100, 30000]."
1109
+ ),
1110
+ "default": 5000,
1111
+ "minimum": 100,
1112
+ "maximum": 30000,
1113
+ },
1114
+ "engine": {
1115
+ "type": "string",
1116
+ "description": (
1117
+ "Engine identifier (e.g. 'faster-whisper', "
1118
+ "'openai-whisper'). Default 'faster-whisper'."
1119
+ ),
1120
+ "default": "faster-whisper",
1121
+ },
1122
+ "language": {
1123
+ "type": "string",
1124
+ "description": (
1125
+ "ISO 639-1 language code (e.g. 'ja'). Pass "
1126
+ "an empty string or omit for autodetect."
1127
+ ),
1128
+ "default": "ja",
1129
+ },
1130
+ "model": {
1131
+ "type": "string",
1132
+ "description": (
1133
+ "Engine-specific model identifier (e.g. "
1134
+ "'base' / 'small' / 'medium' for faster-"
1135
+ "whisper, 'whisper-1' for OpenAI). Engines "
1136
+ "fall back to their default when omitted."
1137
+ ),
1138
+ },
1139
+ "motion": {
1140
+ "type": "string",
1141
+ "enum": ["none", "face-only", "look-up"],
1142
+ "description": (
1143
+ "Optional visible feedback during capture. "
1144
+ "'none' preserves the previous behaviour. "
1145
+ "'face-only' shows the thinking avatar during "
1146
+ "capture and restores idle at the end. "
1147
+ "'look-up' preserves yaw, tilts pitch to "
1148
+ "look_up_pitch, and holds the pose on success."
1149
+ ),
1150
+ "default": "none",
1151
+ },
1152
+ "look_up_pitch": {
1153
+ "type": "number",
1154
+ "description": (
1155
+ "Pitch angle for motion='look-up'. Must be "
1156
+ "between 5 and 85 degrees."
1157
+ ),
1158
+ "default": 50.0,
1159
+ "minimum": 5,
1160
+ "maximum": 85,
1161
+ },
1162
+ },
1163
+ },
1164
+ ),
1165
+ Tool(
1166
+ name="i2c_scan",
1167
+ description=(
1168
+ "Scan the external I2C bus on Grove Port A and return "
1169
+ "all 7-bit addresses (probe range 0x08..0x77, "
1170
+ "excluding I2C reserved ranges) that ACK a probe. Use "
1171
+ "this to discover attached M5Stack Unit modules "
1172
+ "(ENV III, ToF, gas sensor, PaHub, etc.). On-board ICs "
1173
+ "on the internal bus are NOT included (this tool "
1174
+ "operates on a physically separate bus). Returns "
1175
+ "{\"ok\": true, \"addresses\": [...]}."
1176
+ ),
1177
+ inputSchema={
1178
+ "type": "object",
1179
+ "properties": {},
1180
+ },
1181
+ ),
1182
+ Tool(
1183
+ name="i2c_read",
1184
+ description=(
1185
+ "Read n_bytes from an I2C device at 7-bit address "
1186
+ "`addr` on Grove Port A. Use this for protocols that "
1187
+ "read the device's current register / output without "
1188
+ "a preceding write. For typical 'write register "
1189
+ "address, then read' patterns, use `i2c_write_read` "
1190
+ "instead. Returns "
1191
+ "{\"ok\": true, \"bytes\": [...]} or "
1192
+ "{\"ok\": false, \"error\": \"ESP_ERR_TIMEOUT\"} on NACK."
1193
+ ),
1194
+ inputSchema={
1195
+ "type": "object",
1196
+ "properties": {
1197
+ "addr": {
1198
+ "type": "integer",
1199
+ "description": (
1200
+ "7-bit I2C address; range 0x08..0x77 "
1201
+ "(I2C reserved ranges excluded — matches "
1202
+ "the i2c_scan probe range)."
1203
+ ),
1204
+ "minimum": 8,
1205
+ "maximum": 119,
1206
+ },
1207
+ "n_bytes": {
1208
+ "type": "integer",
1209
+ "description": "Bytes to read (1..256).",
1210
+ "minimum": 1,
1211
+ "maximum": 256,
1212
+ },
1213
+ },
1214
+ "required": ["addr", "n_bytes"],
1215
+ },
1216
+ ),
1217
+ Tool(
1218
+ name="i2c_write",
1219
+ description=(
1220
+ "Write bytes to an I2C device at 7-bit address `addr` "
1221
+ "on Grove Port A. `bytes` is an array of integers "
1222
+ "(0..255). This tool operates on the external Port A "
1223
+ "bus only; on-board ICs (PMIC, AW9523, touch, etc.) "
1224
+ "on the internal bus are not reachable."
1225
+ ),
1226
+ inputSchema={
1227
+ "type": "object",
1228
+ "properties": {
1229
+ "addr": {
1230
+ "type": "integer",
1231
+ "description": (
1232
+ "7-bit I2C address; range 0x08..0x77 "
1233
+ "(I2C reserved ranges excluded — matches "
1234
+ "the i2c_scan probe range)."
1235
+ ),
1236
+ "minimum": 8,
1237
+ "maximum": 119,
1238
+ },
1239
+ "bytes": {
1240
+ "type": "array",
1241
+ "description": "Bytes to write (each 0..255).",
1242
+ "items": {
1243
+ "type": "integer",
1244
+ "minimum": 0,
1245
+ "maximum": 255,
1246
+ },
1247
+ },
1248
+ },
1249
+ "required": ["addr", "bytes"],
1250
+ },
1251
+ ),
1252
+ Tool(
1253
+ name="i2c_write_read",
1254
+ description=(
1255
+ "Write `write_bytes` to an I2C device at 7-bit address "
1256
+ "`addr` on Grove Port A, then read `n_bytes` back in a "
1257
+ "single Repeated Start transaction. Common 'set "
1258
+ "register pointer, then read' idiom: pass "
1259
+ "write_bytes=[reg_addr] to read from a specific "
1260
+ "register."
1261
+ ),
1262
+ inputSchema={
1263
+ "type": "object",
1264
+ "properties": {
1265
+ "addr": {
1266
+ "type": "integer",
1267
+ "description": (
1268
+ "7-bit I2C address; range 0x08..0x77 "
1269
+ "(I2C reserved ranges excluded — matches "
1270
+ "the i2c_scan probe range)."
1271
+ ),
1272
+ "minimum": 8,
1273
+ "maximum": 119,
1274
+ },
1275
+ "write_bytes": {
1276
+ "type": "array",
1277
+ "description": (
1278
+ "Bytes to write before reading "
1279
+ "(each 0..255)."
1280
+ ),
1281
+ "items": {
1282
+ "type": "integer",
1283
+ "minimum": 0,
1284
+ "maximum": 255,
1285
+ },
1286
+ },
1287
+ "n_bytes": {
1288
+ "type": "integer",
1289
+ "description": "Bytes to read (1..256).",
1290
+ "minimum": 1,
1291
+ "maximum": 256,
1292
+ },
1293
+ },
1294
+ "required": ["addr", "write_bytes", "n_bytes"],
1295
+ },
1296
+ ),
1297
+ Tool(
1298
+ name="load_avatar_set",
1299
+ description=(
1300
+ "Load a dynamic avatar set onto the connected ESP32 "
1301
+ "(Phase 4.5 avatar pipeline). The gateway stages the "
1302
+ "payload on its HTTP server, notifies the device via "
1303
+ "WebSocket, and the device fetches + SHA256-verifies + "
1304
+ "loads it into PSRAM. ``archive_path`` must point to a "
1305
+ "raw RGB565 file on the gateway host: layered mode = "
1306
+ "14 frames (face 6 + eyes 3 + mouth 5) totalling "
1307
+ "537,600 bytes; matrix mode = 90 frames (6 × 3 × 5) "
1308
+ "totalling 3,456,000 bytes. Returns ok / checksum / "
1309
+ "bytes_transferred / error."
1310
+ ),
1311
+ inputSchema={
1312
+ "type": "object",
1313
+ "properties": {
1314
+ "archive_path": {
1315
+ "type": "string",
1316
+ "description": (
1317
+ "Filesystem path on the gateway host to "
1318
+ "the raw RGB565 payload."
1319
+ ),
1320
+ },
1321
+ "mode": {
1322
+ "type": "string",
1323
+ "enum": ["layered", "matrix"],
1324
+ "description": (
1325
+ "'layered' (14 frames, ~525 KB) or "
1326
+ "'matrix' (90 frames, ~3.3 MB)."
1327
+ ),
1328
+ },
1329
+ "timeout": {
1330
+ "type": "number",
1331
+ "description": (
1332
+ "Max seconds to wait for the device's "
1333
+ "avatar_set_loaded reply."
1334
+ ),
1335
+ "default": 60.0,
1336
+ "minimum": 5.0,
1337
+ "maximum": 300.0,
1338
+ },
1339
+ },
1340
+ "required": ["archive_path", "mode"],
1341
+ },
1342
+ ),
1343
+ ]
1344
+
1345
+ @server.call_tool()
1346
+ async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]:
1347
+ """Handle a tool call by relaying to ESP32."""
1348
+ arguments = arguments or {}
1349
+ return await _dispatch_mcp_tool(name, arguments, get_gateway())
1350
+
1351
+ return server
1352
+
1353
+
1354
+ async def run_stdio_server(notify_config: NotifyConfig | None = None) -> None:
1355
+ """Run the MCP server on stdio."""
1356
+ if notify_config is None:
1357
+ notify_config = load_notify_config()
1358
+ server = create_server(notify_config=notify_config)
1359
+ async with stdio_server() as (read_stream, write_stream):
1360
+ logger.info("stdio MCP server starting")
1361
+ await server.run(
1362
+ read_stream,
1363
+ write_stream,
1364
+ _create_initialization_options(server, notify_config),
1365
+ )