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,398 @@
1
+ """Streamable HTTP MCP daemon wiring for the StackChan gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import ipaddress
8
+ import json
9
+ import os
10
+ import time
11
+ import uuid
12
+ from collections.abc import Awaitable, Callable
13
+ from typing import Any
14
+ from urllib.parse import urlparse
15
+
16
+ import jsonschema
17
+ from mcp.server.streamable_http import MCP_SESSION_ID_HEADER
18
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
19
+ from mcp.types import CallToolRequest, CallToolResult, ErrorData, ServerResult, TextContent
20
+ from starlette.applications import Starlette
21
+ from starlette.requests import Request
22
+ from starlette.responses import JSONResponse, PlainTextResponse
23
+ from starlette.routing import Route
24
+ from starlette.types import Receive, Scope, Send
25
+
26
+ from .notify_config import NotifyConfig
27
+ from .queue import CommandQueue, QueueFull, QueueItem, build_queue_full_error
28
+ from .stdio_server import _dispatch_mcp_tool, create_server
29
+
30
+ BYPASS_TOOLS = frozenset({"get_status"})
31
+ MCP_HTTP_ALLOWED_HOSTS_ENV = "MCP_HTTP_ALLOWED_HOSTS"
32
+ AUTH_FAILURE_MESSAGE = "Unauthorized: missing or invalid bearer token"
33
+ HOST_FAILURE_MESSAGE = "Forbidden: invalid Host header"
34
+ ORIGIN_FAILURE_MESSAGE = "Forbidden: invalid Origin header"
35
+ NON_LOOPBACK_TOKEN_REQUIRED_MESSAGE = (
36
+ "stackchan-mcp: refusing non-loopback MCP_HTTP_HOST without "
37
+ "STACKCHAN_TOKEN or BEARER_TOKEN"
38
+ )
39
+ DISCONNECTED_DEVICE_PAYLOAD = {
40
+ "error": "No ESP32 device connected. Please check the device."
41
+ }
42
+ SERVER_SHUTDOWN_ERROR_CODE = -32000
43
+ SERVER_SHUTDOWN_ERROR_MESSAGE = "stackchan MCP HTTP server is shutting down"
44
+
45
+ DispatchFn = Callable[[QueueItem], Awaitable[list[TextContent]]]
46
+
47
+
48
+ def get_configured_token() -> str | None:
49
+ """Return the configured HTTP bearer token, if any."""
50
+ return os.getenv("STACKCHAN_TOKEN") or os.getenv("BEARER_TOKEN") or None
51
+
52
+
53
+ def is_wildcard_bind_host(host: str) -> bool:
54
+ """Return whether ``host`` binds all local interfaces."""
55
+ normalized = host.strip().lower()
56
+ return normalized in {"", "0.0.0.0", "::"}
57
+
58
+
59
+ def is_loopback_bind_host(host: str) -> bool:
60
+ """Return whether ``host`` is a loopback-only bind target."""
61
+ normalized = host.strip().lower()
62
+ if normalized in {"localhost", "127.0.0.1", "::1"}:
63
+ return True
64
+ try:
65
+ return ipaddress.ip_address(normalized).is_loopback
66
+ except ValueError:
67
+ return False
68
+
69
+
70
+ def validate_bind_safety(host: str, token: str | None) -> None:
71
+ """Reject non-loopback daemon binds when no HTTP bearer token is set."""
72
+ if not token and not is_loopback_bind_host(host):
73
+ raise ValueError(NON_LOOPBACK_TOKEN_REQUIRED_MESSAGE)
74
+
75
+
76
+ def make_dispatch_fn(gateway: Any) -> DispatchFn:
77
+ """Build the single-flight ESP32 dispatcher used by the command queue."""
78
+
79
+ async def dispatch(item: QueueItem) -> list[TextContent]:
80
+ if not gateway.esp32.device_connected:
81
+ return [
82
+ TextContent(
83
+ type="text",
84
+ text=json.dumps(DISCONNECTED_DEVICE_PAYLOAD),
85
+ )
86
+ ]
87
+ return await _dispatch_mcp_tool(item.tool_name, item.arguments, gateway)
88
+
89
+ return dispatch
90
+
91
+
92
+ def build_app(
93
+ queue: CommandQueue,
94
+ *,
95
+ gateway: Any,
96
+ owner_id: str,
97
+ host: str,
98
+ port: int,
99
+ token: str | None = None,
100
+ dispatch_fn: DispatchFn | None = None,
101
+ notify_config: NotifyConfig | None = None,
102
+ ) -> _GuardedASGIApp:
103
+ """Build the ASGI app for Streamable HTTP MCP plus health endpoints."""
104
+ server = create_server(notify_config=notify_config)
105
+ session_manager = StreamableHTTPSessionManager(
106
+ app=server,
107
+ json_response=True,
108
+ stateless=False,
109
+ )
110
+ pending_items: dict[str, QueueItem] = {}
111
+ _install_queue_tool_handler(
112
+ server,
113
+ queue=queue,
114
+ gateway=gateway,
115
+ pending_items=pending_items,
116
+ )
117
+
118
+ async def healthz(_request: Request) -> JSONResponse:
119
+ return JSONResponse({"ok": True})
120
+
121
+ async def status(_request: Request) -> JSONResponse:
122
+ raw_status = gateway.esp32.get_status()
123
+ status_payload = dict(raw_status) if isinstance(raw_status, dict) else {}
124
+ if not isinstance(raw_status, dict):
125
+ status_payload["status"] = raw_status
126
+ status_payload.update(
127
+ {
128
+ "esp32_connected": bool(gateway.esp32.device_connected),
129
+ "queue_depth": queue.depth,
130
+ "queue_capacity": queue.capacity,
131
+ "owner_id": owner_id,
132
+ "connected_clients": _connected_client_count(session_manager),
133
+ }
134
+ )
135
+ return JSONResponse(status_payload)
136
+
137
+ @contextlib.asynccontextmanager
138
+ async def lifespan(_app: Starlette):
139
+ dispatcher_task: asyncio.Task[None] | None = None
140
+ async with session_manager.run():
141
+ if dispatch_fn is not None:
142
+ dispatcher_task = asyncio.create_task(
143
+ queue.run_dispatcher(_skip_done_dispatch(dispatch_fn))
144
+ )
145
+ try:
146
+ yield
147
+ finally:
148
+ if dispatcher_task is not None:
149
+ dispatcher_task.cancel()
150
+ with contextlib.suppress(asyncio.CancelledError):
151
+ await dispatcher_task
152
+ _complete_pending_items_for_shutdown(pending_items)
153
+ _drain_queued_items_for_shutdown(queue)
154
+
155
+ routes = [
156
+ Route(
157
+ "/mcp",
158
+ endpoint=_StreamableHTTPASGIApp(session_manager),
159
+ methods=["GET", "POST", "DELETE"],
160
+ ),
161
+ Route("/healthz", endpoint=healthz, methods=["GET"]),
162
+ Route("/status", endpoint=status, methods=["GET"]),
163
+ ]
164
+ app = Starlette(routes=routes, lifespan=lifespan)
165
+ app.state.command_queue = queue
166
+ app.state.session_manager = session_manager
167
+ app.state.gateway = gateway
168
+ return _GuardedASGIApp(
169
+ app,
170
+ token=token,
171
+ allowed_hosts=_allowed_host_values(host, port),
172
+ )
173
+
174
+
175
+ def _install_queue_tool_handler(
176
+ server: Any,
177
+ *,
178
+ queue: CommandQueue,
179
+ gateway: Any,
180
+ pending_items: dict[str, QueueItem],
181
+ ) -> None:
182
+ async def handler(req: CallToolRequest) -> ServerResult | ErrorData:
183
+ tool_name = req.params.name
184
+ arguments = req.params.arguments or {}
185
+ tool = await server._get_cached_tool_definition(tool_name)
186
+ if tool is not None:
187
+ try:
188
+ jsonschema.validate(instance=arguments, schema=tool.inputSchema)
189
+ except jsonschema.ValidationError as exc:
190
+ return server._make_error_result(
191
+ f"Input validation error: {exc.message}"
192
+ )
193
+
194
+ if tool_name in BYPASS_TOOLS:
195
+ content = await _dispatch_mcp_tool(tool_name, arguments, gateway)
196
+ return _tool_result(content)
197
+
198
+ context = server.request_context
199
+ request = context.request
200
+ client_session_id = None
201
+ if isinstance(request, Request):
202
+ client_session_id = request.headers.get(MCP_SESSION_ID_HEADER)
203
+
204
+ response_future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
205
+ item = QueueItem(
206
+ correlation_id=str(uuid.uuid4()),
207
+ client_session_id=client_session_id,
208
+ client_request_id=context.request_id,
209
+ tool_name=tool_name,
210
+ arguments=arguments,
211
+ response_future=response_future,
212
+ enqueued_at=time.monotonic(),
213
+ )
214
+ try:
215
+ queue.enqueue(item)
216
+ except QueueFull as exc:
217
+ return ErrorData(**build_queue_full_error(exc.queue_depth))
218
+
219
+ pending_items[item.correlation_id] = item
220
+ try:
221
+ content_or_error = await response_future
222
+ except asyncio.CancelledError:
223
+ response_future.cancel()
224
+ raise
225
+ finally:
226
+ if response_future.done():
227
+ pending_items.pop(item.correlation_id, None)
228
+
229
+ if isinstance(content_or_error, ErrorData):
230
+ return content_or_error
231
+ return _tool_result(content_or_error)
232
+
233
+ server.request_handlers[CallToolRequest] = handler
234
+
235
+
236
+ def _skip_done_dispatch(dispatch_fn: DispatchFn) -> DispatchFn:
237
+ async def dispatch(item: QueueItem) -> list[TextContent]:
238
+ if item.response_future.done():
239
+ return []
240
+ return await dispatch_fn(item)
241
+
242
+ return dispatch
243
+
244
+
245
+ def _complete_pending_items_for_shutdown(
246
+ pending_items: dict[str, QueueItem],
247
+ ) -> None:
248
+ for item in list(pending_items.values()):
249
+ _complete_item_with_shutdown_error(item)
250
+ pending_items.clear()
251
+
252
+
253
+ def _drain_queued_items_for_shutdown(queue: CommandQueue) -> None:
254
+ raw_queue = getattr(queue, "_queue")
255
+ while True:
256
+ try:
257
+ item = raw_queue.get_nowait()
258
+ except asyncio.QueueEmpty:
259
+ return
260
+ _complete_item_with_shutdown_error(item)
261
+ raw_queue.task_done()
262
+
263
+
264
+ def _complete_item_with_shutdown_error(item: QueueItem) -> None:
265
+ if not item.response_future.done():
266
+ item.response_future.set_result(_server_shutdown_error())
267
+
268
+
269
+ def _server_shutdown_error() -> ErrorData:
270
+ return ErrorData(
271
+ code=SERVER_SHUTDOWN_ERROR_CODE,
272
+ message=SERVER_SHUTDOWN_ERROR_MESSAGE,
273
+ data={"reason": "server_shutdown"},
274
+ )
275
+
276
+
277
+ def _tool_result(content: list[TextContent]) -> ServerResult:
278
+ return ServerResult(
279
+ CallToolResult(
280
+ content=content,
281
+ isError=False,
282
+ )
283
+ )
284
+
285
+
286
+ def _connected_client_count(session_manager: StreamableHTTPSessionManager) -> int:
287
+ return len(getattr(session_manager, "_server_instances", {}))
288
+
289
+
290
+ def _allowed_host_values(host: str, port: int) -> set[str]:
291
+ hosts = {host.strip().lower()}
292
+ if is_loopback_bind_host(host) or is_wildcard_bind_host(host):
293
+ hosts.update({"127.0.0.1", "localhost", "::1"})
294
+
295
+ values: set[str] = set()
296
+ for item in hosts:
297
+ values.add(item)
298
+ values.add(_host_with_port(item, port))
299
+ values.update(_allowed_hosts_from_env(port))
300
+ return values
301
+
302
+
303
+ def _allowed_hosts_from_env(port: int) -> set[str]:
304
+ raw_hosts = os.getenv(MCP_HTTP_ALLOWED_HOSTS_ENV, "")
305
+ values: set[str] = set()
306
+ for raw_item in raw_hosts.split(","):
307
+ item = raw_item.strip().lower()
308
+ if not item:
309
+ continue
310
+ parsed = urlparse(item)
311
+ if parsed.scheme in {"http", "https"} and parsed.netloc:
312
+ item = parsed.netloc.lower()
313
+ values.add(item)
314
+ if ":" not in item or (item.startswith("[") and "]:" not in item):
315
+ values.add(_host_with_port(item, port))
316
+ return values
317
+
318
+
319
+ def _host_with_port(host: str, port: int) -> str:
320
+ if ":" in host and not host.startswith("["):
321
+ return f"[{host}]:{port}"
322
+ return f"{host}:{port}"
323
+
324
+
325
+ def _is_allowed_host_header(value: str | None, allowed_hosts: set[str]) -> bool:
326
+ if not value:
327
+ return False
328
+ return value.strip().lower() in allowed_hosts
329
+
330
+
331
+ def _is_allowed_origin(value: str | None, allowed_hosts: set[str]) -> bool:
332
+ if not value:
333
+ return True
334
+ parsed = urlparse(value)
335
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
336
+ return False
337
+ return _is_allowed_host_header(parsed.netloc, allowed_hosts)
338
+
339
+
340
+ class _GuardedASGIApp:
341
+ def __init__(
342
+ self,
343
+ app: Starlette,
344
+ *,
345
+ token: str | None,
346
+ allowed_hosts: set[str],
347
+ ) -> None:
348
+ self._app = app
349
+ self._token = token
350
+ self._allowed_hosts = allowed_hosts
351
+ self.state = app.state
352
+
353
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
354
+ if scope["type"] != "http":
355
+ await self._app(scope, receive, send)
356
+ return
357
+
358
+ request = Request(scope, receive)
359
+ if not _is_allowed_host_header(request.headers.get("host"), self._allowed_hosts):
360
+ await PlainTextResponse(HOST_FAILURE_MESSAGE, status_code=403)(
361
+ scope,
362
+ receive,
363
+ send,
364
+ )
365
+ return
366
+ if not _is_allowed_origin(request.headers.get("origin"), self._allowed_hosts):
367
+ await PlainTextResponse(ORIGIN_FAILURE_MESSAGE, status_code=403)(
368
+ scope,
369
+ receive,
370
+ send,
371
+ )
372
+ return
373
+ if self._token and scope.get("path") in {"/mcp", "/status"}:
374
+ expected = f"Bearer {self._token}"
375
+ if request.headers.get("authorization") != expected:
376
+ await PlainTextResponse(
377
+ AUTH_FAILURE_MESSAGE,
378
+ status_code=401,
379
+ headers={"WWW-Authenticate": "Bearer"},
380
+ )(scope, receive, send)
381
+ return
382
+
383
+ await self._app(scope, receive, send)
384
+
385
+ async def router_startup(self) -> None:
386
+ await self._app.router.startup()
387
+
388
+ @property
389
+ def router(self) -> Any:
390
+ return self._app.router
391
+
392
+
393
+ class _StreamableHTTPASGIApp:
394
+ def __init__(self, session_manager: StreamableHTTPSessionManager) -> None:
395
+ self._session_manager = session_manager
396
+
397
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
398
+ await self._session_manager.handle_request(scope, receive, send)
@@ -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}")