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.
- stackchan_mcp/__init__.py +81 -0
- stackchan_mcp/__main__.py +12 -0
- stackchan_mcp/_libs/SOURCES.md +130 -0
- stackchan_mcp/_libs/opus.dll +0 -0
- stackchan_mcp/audio_input_hook.py +432 -0
- stackchan_mcp/audio_stream.py +162 -0
- stackchan_mcp/capture_server.py +469 -0
- stackchan_mcp/cli.py +958 -0
- stackchan_mcp/esp32_client.py +983 -0
- stackchan_mcp/event_log.py +189 -0
- stackchan_mcp/gateway.py +274 -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/http_server.py +398 -0
- stackchan_mcp/mcp_router.py +126 -0
- stackchan_mcp/mdns_advertiser.py +347 -0
- stackchan_mcp/notify.example.yml +21 -0
- stackchan_mcp/notify_config.py +235 -0
- stackchan_mcp/ownership.py +270 -0
- stackchan_mcp/protocol.py +95 -0
- stackchan_mcp/queue.py +191 -0
- stackchan_mcp/server.py +28 -0
- stackchan_mcp/stdio_server.py +1365 -0
- stackchan_mcp/stt/__init__.py +62 -0
- stackchan_mcp/stt/audio_utils.py +102 -0
- stackchan_mcp/stt/base.py +94 -0
- stackchan_mcp/stt/faster_whisper.py +217 -0
- stackchan_mcp/stt/openai_whisper.py +177 -0
- stackchan_mcp/stt/orchestrator.py +568 -0
- stackchan_mcp/tools.py +82 -0
- stackchan_mcp/tts/__init__.py +62 -0
- stackchan_mcp/tts/audio_utils.py +177 -0
- stackchan_mcp/tts/base.py +86 -0
- stackchan_mcp/tts/orchestrator.py +688 -0
- stackchan_mcp/tts/voicevox.py +184 -0
- stackchan_mcp-0.9.1.dist-info/METADATA +324 -0
- stackchan_mcp-0.9.1.dist-info/RECORD +43 -0
- stackchan_mcp-0.9.1.dist-info/WHEEL +5 -0
- stackchan_mcp-0.9.1.dist-info/entry_points.txt +2 -0
- stackchan_mcp-0.9.1.dist-info/licenses/LICENSE +39 -0
- 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}")
|