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,270 @@
|
|
|
1
|
+
"""Gateway ownership lock - refuse-mode MVP (#177 Phase A)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal, TypedDict
|
|
13
|
+
|
|
14
|
+
LOCK_DIR = Path.home() / ".stackchan-mcp"
|
|
15
|
+
LOCK_PATH = LOCK_DIR / "owner.lock"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
LockMode = Literal["stdio", "streamable-http"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _BaseLockInfo(TypedDict):
|
|
22
|
+
owner_id: str
|
|
23
|
+
pid: int
|
|
24
|
+
start_ts: str
|
|
25
|
+
host: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LockInfo(_BaseLockInfo, total=False):
|
|
29
|
+
mode: LockMode
|
|
30
|
+
http_endpoint: str | None
|
|
31
|
+
started_by: str | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OwnershipError(RuntimeError):
|
|
35
|
+
"""Raised when ownership cannot be acquired."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _now_iso() -> str:
|
|
39
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_owner_id() -> str:
|
|
43
|
+
env = os.environ.get("STACKCHAN_OWNER_ID")
|
|
44
|
+
if env:
|
|
45
|
+
return env
|
|
46
|
+
return f"stackchan-mcp-{uuid.uuid4().hex[:8]}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_pid_alive(pid: int) -> bool:
|
|
50
|
+
"""Return whether pid is alive without disturbing the target process.
|
|
51
|
+
|
|
52
|
+
On Windows, os.kill(pid, 0) calls TerminateProcess(..., 0), which would
|
|
53
|
+
kill the target process; use a non-destructive Win32 API check instead.
|
|
54
|
+
"""
|
|
55
|
+
if pid <= 0:
|
|
56
|
+
return False
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
return _is_pid_alive_windows(pid)
|
|
59
|
+
try:
|
|
60
|
+
os.kill(pid, 0)
|
|
61
|
+
except ProcessLookupError:
|
|
62
|
+
return False
|
|
63
|
+
except PermissionError:
|
|
64
|
+
return True
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_pid_alive_windows(pid: int) -> bool:
|
|
69
|
+
import ctypes
|
|
70
|
+
import ctypes.wintypes
|
|
71
|
+
|
|
72
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
73
|
+
STILL_ACTIVE = 259
|
|
74
|
+
|
|
75
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
76
|
+
kernel32.OpenProcess.argtypes = [
|
|
77
|
+
ctypes.wintypes.DWORD,
|
|
78
|
+
ctypes.wintypes.BOOL,
|
|
79
|
+
ctypes.wintypes.DWORD,
|
|
80
|
+
]
|
|
81
|
+
kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
|
|
82
|
+
kernel32.GetExitCodeProcess.argtypes = [
|
|
83
|
+
ctypes.wintypes.HANDLE,
|
|
84
|
+
ctypes.POINTER(ctypes.wintypes.DWORD),
|
|
85
|
+
]
|
|
86
|
+
kernel32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL
|
|
87
|
+
kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
|
|
88
|
+
kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
|
|
89
|
+
|
|
90
|
+
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
91
|
+
if not handle:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
exit_code = ctypes.wintypes.DWORD()
|
|
96
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
97
|
+
return False
|
|
98
|
+
return exit_code.value == STILL_ACTIVE
|
|
99
|
+
finally:
|
|
100
|
+
kernel32.CloseHandle(handle)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_lock(path: Path = LOCK_PATH) -> LockInfo | None:
|
|
104
|
+
if not path.exists():
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
except (json.JSONDecodeError, OSError):
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
if not isinstance(raw, dict):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
owner_id = raw.get("owner_id")
|
|
115
|
+
pid = raw.get("pid")
|
|
116
|
+
start_ts = raw.get("start_ts")
|
|
117
|
+
host = raw.get("host")
|
|
118
|
+
if (
|
|
119
|
+
not isinstance(owner_id, str)
|
|
120
|
+
or not isinstance(pid, int)
|
|
121
|
+
or not isinstance(start_ts, str)
|
|
122
|
+
or not isinstance(host, str)
|
|
123
|
+
):
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
info: LockInfo = {
|
|
127
|
+
"owner_id": owner_id,
|
|
128
|
+
"pid": pid,
|
|
129
|
+
"start_ts": start_ts,
|
|
130
|
+
"host": host,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Optional metadata: silently ignore unknown or malformed values so a
|
|
134
|
+
# schema-drift writer (for example a future-mode lock file or a
|
|
135
|
+
# partially compatible external writer) cannot cause read_lock to
|
|
136
|
+
# return None and trick acquire_lock into unlinking a live owner's
|
|
137
|
+
# lock file. The four required #177 base fields above are
|
|
138
|
+
# authoritative for the claim/refuse decision; validating optional
|
|
139
|
+
# metadata is a separate diagnostic concern.
|
|
140
|
+
|
|
141
|
+
mode = raw.get("mode")
|
|
142
|
+
if mode in ("stdio", "streamable-http"):
|
|
143
|
+
info["mode"] = mode
|
|
144
|
+
|
|
145
|
+
if "http_endpoint" in raw:
|
|
146
|
+
http_endpoint = raw["http_endpoint"]
|
|
147
|
+
if http_endpoint is None or isinstance(http_endpoint, str):
|
|
148
|
+
info["http_endpoint"] = http_endpoint
|
|
149
|
+
|
|
150
|
+
if "started_by" in raw:
|
|
151
|
+
started_by = raw["started_by"]
|
|
152
|
+
if started_by is None or isinstance(started_by, str):
|
|
153
|
+
info["started_by"] = started_by
|
|
154
|
+
|
|
155
|
+
return info
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _write_lock_atomic(info: LockInfo, path: Path = LOCK_PATH) -> None:
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
161
|
+
tmp.write_text(json.dumps(info, indent=2), encoding="utf-8")
|
|
162
|
+
try:
|
|
163
|
+
# Link the complete temp file into place only if no owner file exists.
|
|
164
|
+
# This keeps readers from seeing partial JSON and lets exactly one
|
|
165
|
+
# simultaneous startup win the initial claim.
|
|
166
|
+
os.link(tmp, path)
|
|
167
|
+
finally:
|
|
168
|
+
tmp.unlink(missing_ok=True)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def acquire_lock(
|
|
172
|
+
owner_id: str,
|
|
173
|
+
path: Path = LOCK_PATH,
|
|
174
|
+
*,
|
|
175
|
+
mode: LockMode = "stdio",
|
|
176
|
+
http_endpoint: str | None = None,
|
|
177
|
+
started_by: str | None = None,
|
|
178
|
+
) -> LockInfo:
|
|
179
|
+
"""Acquire the ownership lock. Raise OwnershipError on refuse.
|
|
180
|
+
|
|
181
|
+
The default stdio-mode call writes the original #177 lock shape so
|
|
182
|
+
older lock readers and ``stackchan-mcp --check`` output remain
|
|
183
|
+
compatible. Daemon transports can attach optional metadata for
|
|
184
|
+
diagnostics without changing the atomic hardlink claim.
|
|
185
|
+
"""
|
|
186
|
+
if mode not in ("stdio", "streamable-http"):
|
|
187
|
+
raise ValueError(f"unsupported lock mode: {mode!r}")
|
|
188
|
+
if mode == "stdio" and (http_endpoint is not None or started_by is not None):
|
|
189
|
+
raise ValueError(
|
|
190
|
+
"stdio-mode ownership locks must not carry http_endpoint or "
|
|
191
|
+
"started_by; these fields are reserved for non-stdio transports"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
while True:
|
|
195
|
+
existing = read_lock(path)
|
|
196
|
+
if existing is not None:
|
|
197
|
+
if is_pid_alive(existing["pid"]):
|
|
198
|
+
raise OwnershipError(
|
|
199
|
+
"stackchan-mcp: device already owned by "
|
|
200
|
+
f"{existing['owner_id']} "
|
|
201
|
+
f"(pid {existing['pid']}, since {existing['start_ts']})"
|
|
202
|
+
)
|
|
203
|
+
print(
|
|
204
|
+
f"stackchan-mcp: removed stale lock from dead pid {existing['pid']}",
|
|
205
|
+
file=sys.stderr,
|
|
206
|
+
)
|
|
207
|
+
path.unlink(missing_ok=True)
|
|
208
|
+
elif path.exists():
|
|
209
|
+
path.unlink()
|
|
210
|
+
|
|
211
|
+
info: LockInfo = {
|
|
212
|
+
"owner_id": owner_id,
|
|
213
|
+
"pid": os.getpid(),
|
|
214
|
+
"start_ts": _now_iso(),
|
|
215
|
+
"host": socket.gethostname(),
|
|
216
|
+
}
|
|
217
|
+
if mode != "stdio":
|
|
218
|
+
info["mode"] = mode
|
|
219
|
+
if http_endpoint is not None:
|
|
220
|
+
info["http_endpoint"] = http_endpoint
|
|
221
|
+
if started_by is not None:
|
|
222
|
+
info["started_by"] = started_by
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
_write_lock_atomic(info, path)
|
|
226
|
+
except FileExistsError:
|
|
227
|
+
continue
|
|
228
|
+
return info
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def release_lock(path: Path = LOCK_PATH) -> None:
|
|
232
|
+
"""Remove the lock file. Idempotent.
|
|
233
|
+
|
|
234
|
+
This is the legacy, owner-unaware release primitive kept for backward
|
|
235
|
+
compatibility. New callers should prefer :func:`release_lock_if_owner`
|
|
236
|
+
so that a stale cleanup callback cannot unlink a successor process's
|
|
237
|
+
live lock.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
path.unlink()
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def release_lock_if_owner(info: LockInfo, path: Path = LOCK_PATH) -> bool:
|
|
246
|
+
"""Remove the lock file only if it still belongs to ``info``.
|
|
247
|
+
|
|
248
|
+
Returns ``True`` if the lock was removed, ``False`` if the on-disk
|
|
249
|
+
lock has a different ``owner_id`` / ``pid`` / ``start_ts`` or no
|
|
250
|
+
longer exists. This is the owner-scoped counterpart to
|
|
251
|
+
:func:`release_lock` and is intended for cleanup paths (``finally``
|
|
252
|
+
blocks, ``atexit.register``) where the caller may have lost ownership
|
|
253
|
+
between claim and cleanup — for example after the gateway exited and
|
|
254
|
+
a second process acquired the lock before the first process's
|
|
255
|
+
interpreter exit callbacks ran.
|
|
256
|
+
"""
|
|
257
|
+
existing = read_lock(path)
|
|
258
|
+
if existing is None:
|
|
259
|
+
return False
|
|
260
|
+
if (
|
|
261
|
+
existing.get("owner_id") != info.get("owner_id")
|
|
262
|
+
or existing.get("pid") != info.get("pid")
|
|
263
|
+
or existing.get("start_ts") != info.get("start_ts")
|
|
264
|
+
):
|
|
265
|
+
return False
|
|
266
|
+
try:
|
|
267
|
+
path.unlink()
|
|
268
|
+
return True
|
|
269
|
+
except FileNotFoundError:
|
|
270
|
+
return False
|
|
@@ -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/queue.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Bounded command queue for Issue #178 Phase B chunk 1.
|
|
2
|
+
|
|
3
|
+
This module follows the command-queue design in
|
|
4
|
+
``docs/178-http-transport-spike.md`` and intentionally stays independent from
|
|
5
|
+
the HTTP MCP server, MCP SDK objects, and ESP32 gateway wiring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
COMMAND_QUEUE_SIZE_ENV = "STACKCHAN_COMMAND_QUEUE_SIZE"
|
|
20
|
+
DEFAULT_COMMAND_QUEUE_CAPACITY = 32
|
|
21
|
+
QUEUE_FULL_ERROR_CODE = -32000
|
|
22
|
+
QUEUE_FULL_MESSAGE = "stackchan command queue is full"
|
|
23
|
+
QUEUE_FULL_RETRY_AFTER_MS = 250
|
|
24
|
+
|
|
25
|
+
DispatchFn = Callable[["QueueItem"], Awaitable[Any]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class QueueItem:
|
|
30
|
+
"""One ESP32-bound command and the future that receives its response."""
|
|
31
|
+
|
|
32
|
+
correlation_id: str
|
|
33
|
+
client_session_id: str | None
|
|
34
|
+
client_request_id: int | str
|
|
35
|
+
tool_name: str
|
|
36
|
+
arguments: dict[str, Any]
|
|
37
|
+
response_future: asyncio.Future[Any]
|
|
38
|
+
enqueued_at: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class QueueFull(Exception):
|
|
42
|
+
"""Raised by CommandQueue.enqueue when capacity is reached."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, queue_depth: int, capacity: int) -> None:
|
|
45
|
+
self.queue_depth = queue_depth
|
|
46
|
+
self.capacity = capacity
|
|
47
|
+
super().__init__(
|
|
48
|
+
f"{QUEUE_FULL_MESSAGE} (queue_depth={queue_depth}, capacity={capacity})"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CommandQueue:
|
|
53
|
+
"""Asyncio-backed bounded FIFO queue for serialized command dispatch."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, capacity: int | None = None) -> None:
|
|
56
|
+
self._capacity = capacity if capacity is not None else _capacity_from_env()
|
|
57
|
+
if self._capacity < 1:
|
|
58
|
+
raise ValueError("command queue capacity must be at least 1")
|
|
59
|
+
self._queue: asyncio.Queue[QueueItem] = asyncio.Queue(
|
|
60
|
+
maxsize=self._capacity
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def capacity(self) -> int:
|
|
65
|
+
"""Return the maximum number of queued commands."""
|
|
66
|
+
return self._capacity
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def depth(self) -> int:
|
|
70
|
+
"""Return the current number of queued commands."""
|
|
71
|
+
return self._queue.qsize()
|
|
72
|
+
|
|
73
|
+
def enqueue(self, item: QueueItem) -> None:
|
|
74
|
+
"""Add an item without blocking, raising QueueFull on saturation."""
|
|
75
|
+
try:
|
|
76
|
+
self._queue.put_nowait(item)
|
|
77
|
+
except asyncio.QueueFull as exc:
|
|
78
|
+
raise QueueFull(self.depth, self.capacity) from exc
|
|
79
|
+
|
|
80
|
+
async def get(self) -> QueueItem:
|
|
81
|
+
"""Await the next queued item in FIFO order."""
|
|
82
|
+
return await self._queue.get()
|
|
83
|
+
|
|
84
|
+
async def run_dispatcher(self, dispatch_fn: DispatchFn) -> None:
|
|
85
|
+
"""Dispatch commands one at a time and complete each response future.
|
|
86
|
+
|
|
87
|
+
The injected dispatch function is awaited to completion before the next
|
|
88
|
+
queue item is fetched. Exceptions from dispatch_fn are delivered to the
|
|
89
|
+
item's response_future so the dispatcher loop can continue.
|
|
90
|
+
"""
|
|
91
|
+
while True:
|
|
92
|
+
item = await self.get()
|
|
93
|
+
try:
|
|
94
|
+
result = await dispatch_fn(item)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
if not item.response_future.done():
|
|
97
|
+
item.response_future.set_exception(exc)
|
|
98
|
+
else:
|
|
99
|
+
if not item.response_future.done():
|
|
100
|
+
item.response_future.set_result(result)
|
|
101
|
+
finally:
|
|
102
|
+
self._queue.task_done()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_queue_full_error(
|
|
106
|
+
queue_depth: int,
|
|
107
|
+
retry_after_ms: int = QUEUE_FULL_RETRY_AFTER_MS,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Build the JSON-RPC inner error payload for queue saturation."""
|
|
110
|
+
return {
|
|
111
|
+
"code": QUEUE_FULL_ERROR_CODE,
|
|
112
|
+
"message": QUEUE_FULL_MESSAGE,
|
|
113
|
+
"data": {
|
|
114
|
+
"queue_depth": queue_depth,
|
|
115
|
+
"retry_after_ms": retry_after_ms,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _capacity_from_env() -> int:
|
|
121
|
+
raw_capacity = os.getenv(COMMAND_QUEUE_SIZE_ENV)
|
|
122
|
+
if raw_capacity is None or raw_capacity == "":
|
|
123
|
+
return DEFAULT_COMMAND_QUEUE_CAPACITY
|
|
124
|
+
try:
|
|
125
|
+
capacity = int(raw_capacity)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"{COMMAND_QUEUE_SIZE_ENV} must be an integer"
|
|
129
|
+
) from exc
|
|
130
|
+
if capacity < 1:
|
|
131
|
+
raise ValueError(f"{COMMAND_QUEUE_SIZE_ENV} must be at least 1")
|
|
132
|
+
return capacity
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _make_smoke_item(
|
|
136
|
+
response_future: asyncio.Future[Any],
|
|
137
|
+
tool_name: str = "smoke.tool",
|
|
138
|
+
) -> QueueItem:
|
|
139
|
+
return QueueItem(
|
|
140
|
+
correlation_id=str(uuid.uuid4()),
|
|
141
|
+
client_session_id="smoke-session",
|
|
142
|
+
client_request_id=1,
|
|
143
|
+
tool_name=tool_name,
|
|
144
|
+
arguments={"value": "smoke"},
|
|
145
|
+
response_future=response_future,
|
|
146
|
+
enqueued_at=time.monotonic(),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _run_smoke() -> None:
|
|
151
|
+
queue = CommandQueue(capacity=1)
|
|
152
|
+
response_future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
153
|
+
queue.enqueue(_make_smoke_item(response_future))
|
|
154
|
+
|
|
155
|
+
async def dispatch_fn(item: QueueItem) -> dict[str, Any]:
|
|
156
|
+
return {
|
|
157
|
+
"ok": True,
|
|
158
|
+
"correlation_id": item.correlation_id,
|
|
159
|
+
"tool_name": item.tool_name,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
dispatcher = asyncio.create_task(queue.run_dispatcher(dispatch_fn))
|
|
163
|
+
result = await asyncio.wait_for(response_future, timeout=1.0)
|
|
164
|
+
assert result["ok"] is True
|
|
165
|
+
assert result["tool_name"] == "smoke.tool"
|
|
166
|
+
|
|
167
|
+
dispatcher.cancel()
|
|
168
|
+
with suppress(asyncio.CancelledError):
|
|
169
|
+
await dispatcher
|
|
170
|
+
|
|
171
|
+
full_queue = CommandQueue(capacity=1)
|
|
172
|
+
full_queue.enqueue(
|
|
173
|
+
_make_smoke_item(asyncio.get_running_loop().create_future(), "full.first")
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
full_queue.enqueue(
|
|
177
|
+
_make_smoke_item(
|
|
178
|
+
asyncio.get_running_loop().create_future(),
|
|
179
|
+
"full.second",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
except QueueFull as exc:
|
|
183
|
+
assert exc.queue_depth == 1
|
|
184
|
+
assert build_queue_full_error(exc.queue_depth)["code"] == -32000
|
|
185
|
+
else:
|
|
186
|
+
raise AssertionError("QueueFull was not raised")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
asyncio.run(_run_smoke())
|
|
191
|
+
print("smoke: PASS")
|
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()
|