stackchan-mcp 0.8.0__tar.gz → 0.9.0__tar.gz

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 (80) hide show
  1. stackchan_mcp-0.9.0/.gitignore +41 -0
  2. stackchan_mcp-0.9.0/AGENTS.md +79 -0
  3. stackchan_mcp-0.9.0/LICENSE-THIRD-PARTY +65 -0
  4. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/PKG-INFO +47 -9
  5. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/README.md +42 -7
  6. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/pyproject.toml +43 -3
  7. stackchan_mcp-0.9.0/stackchan_mcp/__init__.py +81 -0
  8. stackchan_mcp-0.9.0/stackchan_mcp/_libs/SOURCES.md +130 -0
  9. stackchan_mcp-0.9.0/stackchan_mcp/audio_input_hook.py +432 -0
  10. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/audio_stream.py +11 -0
  11. stackchan_mcp-0.9.0/stackchan_mcp/capture_server.py +469 -0
  12. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/cli.py +390 -25
  13. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/esp32_client.py +359 -2
  14. stackchan_mcp-0.9.0/stackchan_mcp/event_log.py +189 -0
  15. stackchan_mcp-0.9.0/stackchan_mcp/gateway.py +274 -0
  16. stackchan_mcp-0.9.0/stackchan_mcp/http_server.py +398 -0
  17. stackchan_mcp-0.9.0/stackchan_mcp/mdns_advertiser.py +347 -0
  18. stackchan_mcp-0.9.0/stackchan_mcp/notify.example.yml +21 -0
  19. stackchan_mcp-0.9.0/stackchan_mcp/notify_config.py +235 -0
  20. stackchan_mcp-0.9.0/stackchan_mcp/ownership.py +270 -0
  21. stackchan_mcp-0.9.0/stackchan_mcp/queue.py +191 -0
  22. stackchan_mcp-0.9.0/stackchan_mcp/stdio_server.py +1365 -0
  23. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/stt/orchestrator.py +17 -1
  24. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/tts/__init__.py +8 -1
  25. stackchan_mcp-0.9.0/stackchan_mcp/tts/orchestrator.py +688 -0
  26. stackchan_mcp-0.9.0/tests/test_audio_input_hook.py +315 -0
  27. stackchan_mcp-0.9.0/tests/test_capture_server.py +201 -0
  28. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_cli.py +353 -8
  29. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_esp32_client.py +159 -0
  30. stackchan_mcp-0.9.0/tests/test_event_dispatch.py +335 -0
  31. stackchan_mcp-0.9.0/tests/test_event_log.py +369 -0
  32. stackchan_mcp-0.9.0/tests/test_gateway.py +251 -0
  33. stackchan_mcp-0.9.0/tests/test_http_server.py +575 -0
  34. stackchan_mcp-0.9.0/tests/test_mdns_advertiser.py +379 -0
  35. stackchan_mcp-0.9.0/tests/test_notify_config.py +231 -0
  36. stackchan_mcp-0.9.0/tests/test_ownership.py +92 -0
  37. stackchan_mcp-0.9.0/tests/test_send_pcm_audio.py +440 -0
  38. stackchan_mcp-0.9.0/tests/test_send_pcm_stream.py +572 -0
  39. stackchan_mcp-0.9.0/tests/test_stackchan_event.py +274 -0
  40. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_stdio_server.py +252 -4
  41. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_stt_orchestrator.py +38 -1
  42. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/uv.lock +93 -2
  43. stackchan_mcp-0.8.0/.gitignore +0 -30
  44. stackchan_mcp-0.8.0/stackchan_mcp/__init__.py +0 -12
  45. stackchan_mcp-0.8.0/stackchan_mcp/capture_server.py +0 -91
  46. stackchan_mcp-0.8.0/stackchan_mcp/gateway.py +0 -123
  47. stackchan_mcp-0.8.0/stackchan_mcp/stdio_server.py +0 -829
  48. stackchan_mcp-0.8.0/stackchan_mcp/tts/orchestrator.py +0 -282
  49. stackchan_mcp-0.8.0/tests/test_capture_server.py +0 -25
  50. stackchan_mcp-0.8.0/tests/test_gateway.py +0 -77
  51. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/.env.example +0 -0
  52. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/LICENSE +0 -0
  53. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/__main__.py +0 -0
  54. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/handlers/__init__.py +0 -0
  55. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/handlers/audio.py +0 -0
  56. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/handlers/camera.py +0 -0
  57. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/handlers/robot.py +0 -0
  58. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/mcp_router.py +0 -0
  59. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/protocol.py +0 -0
  60. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/server.py +0 -0
  61. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/stt/__init__.py +0 -0
  62. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  63. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/stt/base.py +0 -0
  64. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  65. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  66. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/tools.py +0 -0
  67. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  68. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/tts/base.py +0 -0
  69. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/stackchan_mcp/tts/voicevox.py +0 -0
  70. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/_audio_fixtures.py +0 -0
  71. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/conftest.py +0 -0
  72. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_audio_stream.py +0 -0
  73. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_audio_utils.py +0 -0
  74. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_mcp_router.py +0 -0
  75. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_orchestrator.py +0 -0
  76. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_protocol.py +0 -0
  77. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_stt_audio_utils.py +0 -0
  78. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_stt_framework.py +0 -0
  79. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_tts_framework.py +0 -0
  80. {stackchan_mcp-0.8.0 → stackchan_mcp-0.9.0}/tests/test_voicevox.py +0 -0
@@ -0,0 +1,41 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .venv/
6
+ venv/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+
10
+ # Environment / secrets
11
+ .env
12
+ .env.local
13
+ *.local
14
+
15
+ # Captures (user photos)
16
+ captures/
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+
23
+ # OS
24
+ .DS_Store
25
+ Thumbs.db
26
+
27
+ # Build artifacts
28
+ *.zip
29
+ build/
30
+ dist/
31
+
32
+ # Bundled native binaries. The publish workflow builds `opus.dll` on
33
+ # a Windows runner via vcpkg (verified against a pinned SHA256) and
34
+ # drops it under `stackchan_mcp/_libs/` before running `uv build`.
35
+ # Local Windows development can mirror that by either building opus
36
+ # via vcpkg locally, downloading the same DLL from a release
37
+ # artifact, or installing system libopus and copying into `_libs/`.
38
+ # On Linux / macOS the system package manager already provides
39
+ # libopus and this path stays empty. `SOURCES.md` is tracked so the
40
+ # bundling documentation lives alongside the code that consumes it.
41
+ stackchan_mcp/_libs/opus.dll
@@ -0,0 +1,79 @@
1
+ # gateway/ AGENTS.md (stackchan-mcp)
2
+
3
+ Python MCP gateway (`stackchan_mcp` package) developer guide.
4
+
5
+ For ESP32 firmware build/flash/device operations, see `firmware/AGENTS.md`. For board-specific behavior (servo, touch, avatar), see `firmware/main/boards/stackchan/AGENTS.md`.
6
+
7
+ ## 1. stdio MCP constraint
8
+
9
+ The gateway runs as a **stdio MCP server** — it lives as long as the host process keeps stdin/stdout open.
10
+
11
+ - Editing `.env` does NOT reload the running process. **MCP process restart is required** (restart the host application or reconnect).
12
+ - Killing the gateway process directly does NOT auto-restart it. The host must reconnect.
13
+
14
+ ### Verifying token configuration
15
+
16
+ ```bash
17
+ grep "^STACKCHAN_TOKEN=" gateway/.env
18
+ grep "^BEARER_TOKEN=" gateway/.env # legacy alias
19
+ ```
20
+
21
+ ## 2. `STACKCHAN_TOKEN` vs `BEARER_TOKEN` priority
22
+
23
+ ```python
24
+ expected = os.getenv("STACKCHAN_TOKEN") or os.getenv("BEARER_TOKEN")
25
+ ```
26
+
27
+ **`STACKCHAN_TOKEN` takes priority**; `BEARER_TOKEN` is a legacy alias checked only when the primary is empty. Keep both set to the same value as insurance.
28
+
29
+ The ESP32 firmware token (`CONFIG_DEFAULT_WEBSOCKET_TOKEN` in sdkconfig) must match — see `firmware/AGENTS.md` for details.
30
+
31
+ ## 3. Gateway status check
32
+
33
+ ```bash
34
+ # Process check
35
+ lsof -i :8765 | grep LISTEN # python LISTEN = OK
36
+ pgrep -fl stackchan_mcp # process list
37
+
38
+ # Port conflict check
39
+ lsof -i :8765 # ESTABLISHED sockets are MCP client connections, not conflicts
40
+ ```
41
+
42
+ If `address already in use ('0.0.0.0', 8765)` occurs, identify the existing process with `lsof` before killing.
43
+
44
+ ## 4. LAN IP changes (gateway side)
45
+
46
+ The gateway listens on `0.0.0.0:8765`, so LAN IP changes do not directly affect it. The issue is on the ESP32 side — see `firmware/AGENTS.md` for details.
47
+
48
+ ## 5. Validation commands (pre-PR)
49
+
50
+ ```bash
51
+ cd gateway
52
+ uv sync # resolve dependencies
53
+ uv run pytest # tests must pass
54
+ uv run ruff check . # lint must pass
55
+ ```
56
+
57
+ CI runs the same three commands. A local failure means CI will also fail.
58
+
59
+ ## 6. PyPI publishing
60
+
61
+ - Bump `version` in `gateway/pyproject.toml`
62
+ - Promote `CHANGELOG.md` `[Unreleased]` Gateway subsection to `[X.Y.Z] - YYYY-MM-DD`
63
+ - Tag push (`git tag vX.Y.Z && git push origin vX.Y.Z`) triggers Trusted Publishing
64
+ - Verify with `pipx install --force stackchan-mcp` after publishing
65
+
66
+ ## 7. `.env` edit checklist
67
+
68
+ ```bash
69
+ # 1. Verify values
70
+ grep "^STACKCHAN_TOKEN=" gateway/.env
71
+
72
+ # 2. Restart MCP process (host restart or /mcp reconnect)
73
+
74
+ # 3. Verify reconnection
75
+ # In host: get_status → connected: true / tools_count: 14+
76
+
77
+ # 4. If token mismatch, also update ESP32 sdkconfig
78
+ # See firmware/AGENTS.md for token alignment procedure
79
+ ```
@@ -0,0 +1,65 @@
1
+ Third-party software included with stackchan-mcp
2
+ ==================================================
3
+
4
+ The `stackchan-mcp` gateway is distributed under the MIT license (see
5
+ `LICENSE`). The `win_amd64` wheel additionally bundles native binary
6
+ components that are distributed under their own permissive licenses,
7
+ listed below. Non-Windows wheels and the sdist do not contain these
8
+ binaries.
9
+
10
+ The notices in this file are reproduced verbatim from the upstream
11
+ projects for license-compliance purposes. They apply only to the
12
+ specific binaries listed; the rest of the gateway remains MIT.
13
+
14
+ --------------------------------------------------------------------------------
15
+
16
+ Opus codec (libopus)
17
+ --------------------------------------------------------------------------------
18
+
19
+ Component: `stackchan_mcp/_libs/opus.dll`
20
+ Architecture: `win_amd64` (x86_64)
21
+ Upstream: https://opus-codec.org/
22
+ License: BSD 3-clause + Xiph extension
23
+
24
+ Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic,
25
+ Jean-Marc Valin, Timothy B. Terriberry,
26
+ CSIRO, Gregory Maxwell, Mark Borgerding,
27
+ Erik de Castro Lopo
28
+
29
+ Redistribution and use in source and binary forms, with or without
30
+ modification, are permitted provided that the following conditions
31
+ are met:
32
+
33
+ - Redistributions of source code must retain the above copyright
34
+ notice, this list of conditions and the following disclaimer.
35
+
36
+ - Redistributions in binary form must reproduce the above copyright
37
+ notice, this list of conditions and the following disclaimer in the
38
+ documentation and/or other materials provided with the distribution.
39
+
40
+ - Neither the name of Internet Society, IETF or IETF Trust, nor the
41
+ names of specific contributors, may be used to endorse or promote
42
+ products derived from this software without specific prior written
43
+ permission.
44
+
45
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
46
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
47
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
48
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
49
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
50
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
51
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
52
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
53
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
54
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
55
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
56
+
57
+ Opus is also subject to the Internet Society "Note Well" IPR
58
+ disclosure as referenced from RFC 6716. The IPR statement is
59
+ available at:
60
+
61
+ https://datatracker.ietf.org/ipr/search/?submit=ipr&rfc=6716
62
+
63
+ Provenance and SHA256 for the specific binary shipped in each
64
+ release are recorded in `stackchan_mcp/_libs/SOURCES.md` inside the
65
+ wheel.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackchan-mcp
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP.
5
5
  Project-URL: Homepage, https://github.com/kisaragi-mochi/stackchan-mcp
6
6
  Project-URL: Repository, https://github.com/kisaragi-mochi/stackchan-mcp
@@ -8,6 +8,7 @@ Project-URL: Issues, https://github.com/kisaragi-mochi/stackchan-mcp/issues
8
8
  Author: kisaragi-mochi
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
+ License-File: LICENSE-THIRD-PARTY
11
12
  Keywords: esp32,llm,mcp,robotics,stackchan,xiaozhi
12
13
  Classifier: Development Status :: 3 - Alpha
13
14
  Classifier: Intended Audience :: Developers
@@ -23,10 +24,12 @@ Classifier: Topic :: Software Development :: Libraries
23
24
  Classifier: Topic :: System :: Hardware
24
25
  Requires-Python: >=3.10
25
26
  Requires-Dist: aiohttp>=3
26
- Requires-Dist: mcp>=1.0
27
+ Requires-Dist: mcp<2.0,>=1.27
27
28
  Requires-Dist: pydantic>=2
28
29
  Requires-Dist: python-dotenv
30
+ Requires-Dist: pyyaml>=6
29
31
  Requires-Dist: websockets>=12
32
+ Requires-Dist: zeroconf>=0.149
30
33
  Provides-Extra: stt
31
34
  Requires-Dist: opuslib>=3; extra == 'stt'
32
35
  Provides-Extra: stt-faster-whisper
@@ -120,6 +123,29 @@ Default ports:
120
123
  - WebSocket (ESP32 -> gateway): `0.0.0.0:8765`
121
124
  - HTTP capture (ESP32 -> gateway): `0.0.0.0:8766`
122
125
 
126
+ ## Daemon mode (Phase B)
127
+
128
+ For multi-client setups, run one shared Streamable HTTP daemon instead of
129
+ letting each MCP client spawn its own stdio gateway:
130
+
131
+ ```bash
132
+ uv run stackchan-mcp serve --transport streamable-http
133
+ ```
134
+
135
+ The daemon exposes MCP at `http://127.0.0.1:8767/mcp` by default, keeps the
136
+ existing ESP32 WebSocket and capture listeners, and serializes ESP32-bound
137
+ tool calls through a bounded command queue. See
138
+ [`../docs/178-daemon-setup.md`](../docs/178-daemon-setup.md) for environment
139
+ variables, bearer-token rules, `MCP_HTTP_ALLOWED_HOSTS`, bind safety, and
140
+ migration notes.
141
+
142
+ The zero-subcommand stdio mode remains supported and unchanged for existing
143
+ client configs.
144
+
145
+ By default, the gateway advertises the WebSocket endpoint as
146
+ `_stackchan-mcp._tcp.local.` via mDNS/DNS-SD so fresh firmware can discover it
147
+ on the local LAN. Run `stackchan-mcp --no-mdns` to disable this advertisement.
148
+
123
149
  For non-LAN setups, see [`../docs/remote-access.md`](../docs/remote-access.md)
124
150
  for the Tailscale Funnel flow.
125
151
 
@@ -131,10 +157,10 @@ again, check `get_status` from the stdio MCP side to confirm the device is back.
131
157
  ## Configuration changes
132
158
 
133
159
  The gateway reads `.env` once at process start. Because the gateway runs as a
134
- **stdio MCP server** (it has no standalone CLI mode beyond `--help` /
135
- `--version` / `--check`), editing `.env` while it is connected to an MCP
136
- client does not take effect on the running process and killing the gateway
137
- process directly will not auto-restart it; the MCP client owns the lifecycle.
160
+ **stdio MCP server** by default, editing `.env` while it is connected to an MCP
161
+ client does not take effect on the running process and killing that stdio
162
+ gateway process directly will not auto-restart it; the MCP client owns the
163
+ lifecycle. In daemon mode, restart the daemon process after changing `.env`.
138
164
 
139
165
  After editing `.env` (for example to update `STACKCHAN_TOKEN`, `VISION_URL`,
140
166
  or `VISION_TOKEN`):
@@ -278,9 +304,21 @@ asyncio.run(smoke())
278
304
 
279
305
  ## License
280
306
 
281
- The gateway is distributed under the MIT License (see `LICENSE`). The
282
- parent monorepo's `firmware/` directory contains SCServo_lib code under
283
- GPL-3.0, but those files live only inside
307
+ The gateway Python code is distributed under the **MIT License** (see
308
+ `LICENSE`). The Windows wheel (`*-win_amd64.whl`) additionally bundles
309
+ a native `opus.dll` built from upstream Opus source via vcpkg by the
310
+ publish workflow. That binary is distributed under the **BSD 3-clause
311
+ license + Xiph extension**; the full notice ships in every
312
+ distribution form (sdist, `py3-none-any` wheel, `win_amd64` wheel) as
313
+ `LICENSE-THIRD-PARTY`. Non-Windows wheels and the sdist do not contain
314
+ any binary subject to that license — they rely on a system `libopus`
315
+ provided by the OS package manager (e.g. `apt install libopus0`,
316
+ `brew install opus`). See `stackchan_mcp/_libs/SOURCES.md` (also
317
+ shipped in the wheel) for build provenance and the per-release
318
+ SHA256 logged by CI.
319
+
320
+ The parent monorepo's `firmware/` directory contains SCServo_lib code
321
+ under GPL-3.0, but those files live only inside
284
322
  `firmware/main/boards/stackchan/` and never enter this package. The
285
323
  gateway and firmware communicate only over WebSocket, so the GPL/MIT
286
324
  boundary is preserved at the process level.
@@ -75,6 +75,29 @@ Default ports:
75
75
  - WebSocket (ESP32 -> gateway): `0.0.0.0:8765`
76
76
  - HTTP capture (ESP32 -> gateway): `0.0.0.0:8766`
77
77
 
78
+ ## Daemon mode (Phase B)
79
+
80
+ For multi-client setups, run one shared Streamable HTTP daemon instead of
81
+ letting each MCP client spawn its own stdio gateway:
82
+
83
+ ```bash
84
+ uv run stackchan-mcp serve --transport streamable-http
85
+ ```
86
+
87
+ The daemon exposes MCP at `http://127.0.0.1:8767/mcp` by default, keeps the
88
+ existing ESP32 WebSocket and capture listeners, and serializes ESP32-bound
89
+ tool calls through a bounded command queue. See
90
+ [`../docs/178-daemon-setup.md`](../docs/178-daemon-setup.md) for environment
91
+ variables, bearer-token rules, `MCP_HTTP_ALLOWED_HOSTS`, bind safety, and
92
+ migration notes.
93
+
94
+ The zero-subcommand stdio mode remains supported and unchanged for existing
95
+ client configs.
96
+
97
+ By default, the gateway advertises the WebSocket endpoint as
98
+ `_stackchan-mcp._tcp.local.` via mDNS/DNS-SD so fresh firmware can discover it
99
+ on the local LAN. Run `stackchan-mcp --no-mdns` to disable this advertisement.
100
+
78
101
  For non-LAN setups, see [`../docs/remote-access.md`](../docs/remote-access.md)
79
102
  for the Tailscale Funnel flow.
80
103
 
@@ -86,10 +109,10 @@ again, check `get_status` from the stdio MCP side to confirm the device is back.
86
109
  ## Configuration changes
87
110
 
88
111
  The gateway reads `.env` once at process start. Because the gateway runs as a
89
- **stdio MCP server** (it has no standalone CLI mode beyond `--help` /
90
- `--version` / `--check`), editing `.env` while it is connected to an MCP
91
- client does not take effect on the running process and killing the gateway
92
- process directly will not auto-restart it; the MCP client owns the lifecycle.
112
+ **stdio MCP server** by default, editing `.env` while it is connected to an MCP
113
+ client does not take effect on the running process and killing that stdio
114
+ gateway process directly will not auto-restart it; the MCP client owns the
115
+ lifecycle. In daemon mode, restart the daemon process after changing `.env`.
93
116
 
94
117
  After editing `.env` (for example to update `STACKCHAN_TOKEN`, `VISION_URL`,
95
118
  or `VISION_TOKEN`):
@@ -233,9 +256,21 @@ asyncio.run(smoke())
233
256
 
234
257
  ## License
235
258
 
236
- The gateway is distributed under the MIT License (see `LICENSE`). The
237
- parent monorepo's `firmware/` directory contains SCServo_lib code under
238
- GPL-3.0, but those files live only inside
259
+ The gateway Python code is distributed under the **MIT License** (see
260
+ `LICENSE`). The Windows wheel (`*-win_amd64.whl`) additionally bundles
261
+ a native `opus.dll` built from upstream Opus source via vcpkg by the
262
+ publish workflow. That binary is distributed under the **BSD 3-clause
263
+ license + Xiph extension**; the full notice ships in every
264
+ distribution form (sdist, `py3-none-any` wheel, `win_amd64` wheel) as
265
+ `LICENSE-THIRD-PARTY`. Non-Windows wheels and the sdist do not contain
266
+ any binary subject to that license — they rely on a system `libopus`
267
+ provided by the OS package manager (e.g. `apt install libopus0`,
268
+ `brew install opus`). See `stackchan_mcp/_libs/SOURCES.md` (also
269
+ shipped in the wheel) for build provenance and the per-release
270
+ SHA256 logged by CI.
271
+
272
+ The parent monorepo's `firmware/` directory contains SCServo_lib code
273
+ under GPL-3.0, but those files live only inside
239
274
  `firmware/main/boards/stackchan/` and never enter this package. The
240
275
  gateway and firmware communicate only over WebSocket, so the GPL/MIT
241
276
  boundary is preserved at the process level.
@@ -1,11 +1,16 @@
1
1
  [project]
2
2
  name = "stackchan-mcp"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = "MIT"
8
- license-files = ["LICENSE"]
8
+ # `LICENSE-THIRD-PARTY` carries the BSD-3-Clause + Xiph notice for the
9
+ # native libopus binary bundled into the `win_amd64` wheel. The file is
10
+ # always shipped (sdist and every wheel variant) so license scanners
11
+ # pointed at any distribution form can see it, even though the binary
12
+ # itself is only present in the `win_amd64` wheel.
13
+ license-files = ["LICENSE", "LICENSE-THIRD-PARTY"]
9
14
  authors = [
10
15
  { name = "kisaragi-mochi" },
11
16
  ]
@@ -28,8 +33,19 @@ dependencies = [
28
33
  "websockets>=12",
29
34
  "pydantic>=2",
30
35
  "python-dotenv",
31
- "mcp>=1.0",
36
+ "PyYAML>=6",
37
+ # The stdio server captures the active ServerSession via a subclassed
38
+ # Server.run() loop because the public MCP SDK does not yet expose a
39
+ # stable hook for server-side notifications. That subclass relies on
40
+ # `Server._experimental_handlers` and `Server._handle_message`, which
41
+ # are private members. We pin to the verified 1.x line and gate access
42
+ # behind a startup compatibility guard (see stdio_server.py) so a
43
+ # future SDK release cannot silently break the gateway via PyPI
44
+ # auto-resolution. Bump the upper bound after verifying the next
45
+ # major line.
46
+ "mcp>=1.27,<2.0",
32
47
  "aiohttp>=3",
48
+ "zeroconf>=0.149",
33
49
  ]
34
50
 
35
51
  [project.optional-dependencies]
@@ -84,3 +100,27 @@ dev = [
84
100
  [build-system]
85
101
  requires = ["hatchling"]
86
102
  build-backend = "hatchling.build"
103
+
104
+ # Bundle the native libopus binary into the Windows wheel so that
105
+ # `pip install stackchan-mcp[tts]` (or `[stt]`) works out-of-the-box
106
+ # on Windows. The DLL is loaded at import time via
107
+ # `os.add_dll_directory()` in `stackchan_mcp/__init__.py`. See
108
+ # `stackchan_mcp/_libs/SOURCES.md` for provenance and license.
109
+ #
110
+ # Build-time contract: `opus.dll` is NOT tracked in git. The publish
111
+ # workflow builds it from upstream Opus source via vcpkg on a Windows
112
+ # runner (verified against a pinned SHA256), drops it under
113
+ # `stackchan_mcp/_libs/`, then runs `uv build` which produces the
114
+ # `win_amd64` wheel. Builds on non-Windows runners do not place a
115
+ # DLL under `_libs/`, so the sdist and the `py3-none-any` wheel
116
+ # produced on those runners ship without the binary. Glob patterns
117
+ # below match `opus.dll` if and only if it exists at build time, so
118
+ # the same pyproject config covers both wheel variants without an
119
+ # unmatched-pattern error on the non-Windows runner.
120
+ [tool.hatch.build.targets.wheel]
121
+ packages = ["stackchan_mcp"]
122
+ artifacts = [
123
+ "stackchan_mcp/_libs/opus.dll",
124
+ "stackchan_mcp/_libs/SOURCES.md",
125
+ "stackchan_mcp/notify.example.yml",
126
+ ]
@@ -0,0 +1,81 @@
1
+ """stackchan-mcp: Two-faced gateway for StackChan (xiaozhi-esp32).
2
+
3
+ MCP client side: stdio MCP server (mcp Python SDK)
4
+ ESP32 side: WebSocket server (MCP client over JSON-RPC 2.0)
5
+ """
6
+
7
+ import os as _os
8
+ import platform as _platform
9
+ import sys as _sys
10
+ from importlib.metadata import PackageNotFoundError, version
11
+ from pathlib import Path as _Path
12
+
13
+ try:
14
+ __version__ = version("stackchan-mcp")
15
+ except PackageNotFoundError: # pragma: no cover - source checkout without install
16
+ __version__ = "0.0.0+unknown"
17
+
18
+ # Windows: register the bundled native libs directory with the DLL
19
+ # search path before any submodule pulls in `opuslib` (or any other
20
+ # wrapper that calls `ctypes.util.find_library`). On Linux/macOS the
21
+ # system package manager typically already provides libopus, so we do
22
+ # nothing on those platforms.
23
+ #
24
+ # Why this is here and not in tts/__init__.py or stt/__init__.py:
25
+ # opuslib's libopus lookup happens at import time (the wrapper's
26
+ # top-level module unconditionally calls `find_library('opus')` and
27
+ # raises if it returns None). That means we need the DLL search path
28
+ # update to have run before *any* code imports opuslib, no matter
29
+ # which subpackage of stackchan_mcp loads first. The package
30
+ # `__init__.py` is the only place guaranteed to run before all
31
+ # sibling submodules.
32
+ #
33
+ # Why we update BOTH `os.add_dll_directory()` AND `os.environ["PATH"]`:
34
+ # - `os.add_dll_directory()` is the modern, isolated mechanism used by
35
+ # `LoadLibraryEx(..., LOAD_LIBRARY_SEARCH_USER_DIRS)`. Importantly,
36
+ # `ctypes.util.find_library()` on Windows uses the legacy
37
+ # `LoadLibraryW()` path which does **not** consult the
38
+ # `add_dll_directory()` list (see CPython issue #43603). Since
39
+ # `opuslib/api/__init__.py` calls exactly that — `find_library('opus')`
40
+ # — we also have to prepend the directory to PATH so the legacy
41
+ # resolver picks it up.
42
+ # - We add to `add_dll_directory()` too because direct `ctypes.CDLL(...)`
43
+ # / extension-module imports use the modern resolver, and we want
44
+ # bundle discovery to work for both API styles future-proof.
45
+ #
46
+ # See `stackchan_mcp/_libs/SOURCES.md` for the bundled DLL provenance.
47
+ # Architecture gate: the bundled `opus.dll` is built for `win_amd64`
48
+ # (x86_64). On Windows ARM64 / Windows x86 (32-bit), loading the x64
49
+ # DLL would fail with a native-image mismatch — *exactly* the
50
+ # "looks installed but fails at runtime" footgun this bundling is
51
+ # meant to remove. Skip the DLL search-path setup on those
52
+ # architectures so the user falls back to the same
53
+ # "find_library returns None" failure mode they had before this
54
+ # fix, which at least produces a clean ImportError on
55
+ # `import opuslib` rather than a confusing crash inside the DLL
56
+ # loader. A platform-specific wheel build would have rejected those
57
+ # architectures at install time (no compatible wheel), so this
58
+ # guard mostly matters for users who bypass wheel selection (e.g.
59
+ # by installing from sdist on a non-x64 Windows host).
60
+ _machine = _platform.machine().upper() if _sys.platform == "win32" else ""
61
+ _dll_dir_handle = None # kept alive at module scope; see comment below
62
+
63
+ if _sys.platform == "win32" and _machine in ("AMD64", "X86_64"):
64
+ _libs_dir = _Path(__file__).resolve().parent / "_libs"
65
+ if _libs_dir.is_dir():
66
+ # Retain the directory handle at module scope. Per CPython docs
67
+ # (`os.add_dll_directory`), the returned object is "an opaque
68
+ # value that has a `close()` method ... the returned object
69
+ # remains valid until close() is called". On garbage
70
+ # collection, the directory de-registers itself, so direct
71
+ # `ctypes.CDLL(...)` callers that rely on the modern resolver
72
+ # path would lose access to the bundle. Holding the handle on
73
+ # the module keeps the registration live for the process
74
+ # lifetime — matching the intent documented above for both
75
+ # `find_library` (legacy) and `LoadLibraryEx` (modern) lookup
76
+ # paths.
77
+ _dll_dir_handle = _os.add_dll_directory(str(_libs_dir))
78
+ _libs_str = str(_libs_dir)
79
+ _existing_path = _os.environ.get("PATH", "")
80
+ if _libs_str not in _existing_path.split(_os.pathsep):
81
+ _os.environ["PATH"] = _libs_str + _os.pathsep + _existing_path
@@ -0,0 +1,130 @@
1
+ # Bundled Native Libraries
2
+
3
+ This directory contains pre-built native shared libraries that the
4
+ gateway needs on platforms where the system package manager does not
5
+ typically ship them. They are loaded at import time by
6
+ `stackchan_mcp/__init__.py` via `os.add_dll_directory()` (Windows) so
7
+ that `ctypes.util.find_library()` calls inside Python wrapper packages
8
+ (e.g. `opuslib`) resolve to the bundled copy without any user setup.
9
+
10
+ ## Why bundle?
11
+
12
+ The Python wrapper packages that depend on these libraries (currently
13
+ `opuslib`, pulled in via the `[tts]` and `[stt]` extras) only ship
14
+ Python bindings — they do **not** ship the underlying native library.
15
+ On Linux and macOS most users already have `libopus` available through
16
+ their distro's package manager (`apt install libopus0`,
17
+ `brew install opus`, etc.), but on Windows there is no equivalent
18
+ default install path, which means a plain `pip install stackchan-mcp[tts]`
19
+ fails at runtime with `Could not find Opus library. Make sure it is
20
+ installed.` even though the Python wrappers installed cleanly.
21
+
22
+ Bundling the Windows binary in the wheel removes that footgun: every
23
+ Windows user who installs `stackchan-mcp[tts]` (or `[stt]`) gets a
24
+ working installation on the first try, with no extra `vcpkg` /
25
+ `conda install -c conda-forge libopus` / manual DLL placement step.
26
+
27
+ The decision to bundle (vs. download at install time vs. require source
28
+ build) was made on these criteria:
29
+
30
+ | Criterion | Verdict for libopus |
31
+ |---|---|
32
+ | Maturity of the dependency | Mature (Opus is a frozen IETF codec, RFC 6716, 2012) |
33
+ | Frequency of security advisories | Very low (the codec parser is small and well-audited) |
34
+ | File size | ~480 KB — fits comfortably in the wheel |
35
+ | Re-distribution license | BSD 3-clause (Xiph) — redistribution allowed with attribution |
36
+ | Long-term availability of upstream | Excellent (Xiph.Org maintains the source indefinitely) |
37
+
38
+ If any of those change (e.g. a future ML-based bundle that ships
39
+ hundreds of MB), revisit and consider the "CI downloads a pinned
40
+ version at build time" approach instead.
41
+
42
+ ## opus.dll
43
+
44
+ | Field | Value |
45
+ |---|---|
46
+ | Architecture | x86_64 (`win_amd64`) |
47
+ | License | BSD 3-clause + Xiph extension — see <https://opus-codec.org/license/> |
48
+ | Provenance | Built from upstream Opus source by the publish workflow via vcpkg |
49
+ | Build command | `vcpkg install opus:x64-windows` (CI runner: `windows-latest`) |
50
+
51
+ ### Provenance note
52
+
53
+ `opus.dll` is **not** tracked in git. The publish workflow
54
+ (`.github/workflows/publish.yml`, job `build-windows-wheel`)
55
+ bootstraps a fresh vcpkg checkout on a `windows-latest` runner,
56
+ runs `vcpkg install opus:x64-windows`, copies the produced
57
+ `opus.dll` into `stackchan_mcp/_libs/`, and logs its SHA256 to the
58
+ job log so reviewers can spot vcpkg-side binary drift before a tag
59
+ publishes. The wheel build that follows picks the DLL up via
60
+ `tool.hatch.build.targets.wheel.artifacts` in
61
+ `gateway/pyproject.toml`, and the resulting wheel is renamed from
62
+ `*-py3-none-any.whl` to `*-py3-none-win_amd64.whl` so pip resolves
63
+ it only on Windows x64 installs.
64
+
65
+ Builds on the Ubuntu runner (sdist + the `py3-none-any` wheel they
66
+ produce) do not place a DLL under `_libs/`, so those distributions
67
+ ship clean — non-Windows installs and non-x64 Windows installs
68
+ either fall back to a system `libopus` (Linux/macOS) or get a
69
+ clean "no compatible wheel" install-time message (Windows ARM64 /
70
+ x86 32-bit).
71
+
72
+ ### Local development
73
+
74
+ If you need a local Windows checkout to test the bundling path
75
+ (running `uv build` outside CI), mirror the CI step by:
76
+
77
+ 1. Installing libopus via vcpkg (`vcpkg install opus:x64-windows`)
78
+ and copying the produced DLL into `stackchan_mcp/_libs/opus.dll`.
79
+ 2. Or downloading the same `opus.dll` from a release artifact
80
+ uploaded by the publish workflow.
81
+ 3. Or installing system libopus and copying it into the directory.
82
+
83
+ The DLL is gitignored (see `gateway/.gitignore`) so a local copy
84
+ never sneaks into a commit.
85
+
86
+ ## License compliance
87
+
88
+ The Opus codec is distributed under the 3-clause BSD license (with the
89
+ optional Xiph patent grant), which permits redistribution in source or
90
+ binary form provided the copyright notice and license text are
91
+ preserved. The canonical notice ships at the top of every gateway
92
+ distribution as `LICENSE-THIRD-PARTY` (declared in
93
+ `gateway/pyproject.toml`'s `license-files`); the same text is
94
+ reproduced below as the bundling-rationale narrative for readers of
95
+ this document.
96
+
97
+ ```
98
+ Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic,
99
+ Jean-Marc Valin, Timothy B. Terriberry,
100
+ CSIRO, Gregory Maxwell, Mark Borgerding,
101
+ Erik de Castro Lopo
102
+
103
+ Redistribution and use in source and binary forms, with or without
104
+ modification, are permitted provided that the following conditions
105
+ are met:
106
+
107
+ - Redistributions of source code must retain the above copyright
108
+ notice, this list of conditions and the following disclaimer.
109
+
110
+ - Redistributions in binary form must reproduce the above copyright
111
+ notice, this list of conditions and the following disclaimer in the
112
+ documentation and/or other materials provided with the distribution.
113
+
114
+ - Neither the name of Internet Society, IETF or IETF Trust, nor the
115
+ names of specific contributors, may be used to endorse or promote
116
+ products derived from this software without specific prior written
117
+ permission.
118
+
119
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
120
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
121
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
122
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
123
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
124
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
125
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
126
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
127
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
128
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
129
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
130
+ ```