@coffeerunhobby/mcp-ha-connect 1.1.0 → 1.3.0

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 (123) hide show
  1. package/.env.example +52 -16
  2. package/CLAUDE.md +341 -0
  3. package/Dockerfile +5 -0
  4. package/README.md +39 -4
  5. package/dist/config.d.ts +10 -0
  6. package/dist/config.js +59 -3
  7. package/dist/config.js.map +1 -1
  8. package/dist/haClient/automations.js +2 -2
  9. package/dist/haClient/automations.js.map +1 -1
  10. package/dist/haClient/calendars.js +1 -1
  11. package/dist/haClient/calendars.js.map +1 -1
  12. package/dist/haClient/request.d.ts +2 -1
  13. package/dist/haClient/request.js +20 -11
  14. package/dist/haClient/request.js.map +1 -1
  15. package/dist/haClient/services.js +1 -1
  16. package/dist/haClient/services.js.map +1 -1
  17. package/dist/haClient/states.js +1 -1
  18. package/dist/haClient/states.js.map +1 -1
  19. package/dist/omadaClient/auth.d.ts +2 -1
  20. package/dist/omadaClient/auth.js +19 -30
  21. package/dist/omadaClient/auth.js.map +1 -1
  22. package/dist/omadaClient/client.d.ts +18 -1
  23. package/dist/omadaClient/client.js +27 -0
  24. package/dist/omadaClient/client.js.map +1 -1
  25. package/dist/omadaClient/index.d.ts +3 -1
  26. package/dist/omadaClient/index.js +7 -0
  27. package/dist/omadaClient/index.js.map +1 -1
  28. package/dist/omadaClient/request.d.ts +6 -1
  29. package/dist/omadaClient/request.js +24 -27
  30. package/dist/omadaClient/request.js.map +1 -1
  31. package/dist/server/auth.d.ts +6 -0
  32. package/dist/server/auth.js +22 -10
  33. package/dist/server/auth.js.map +1 -1
  34. package/dist/server/eventSubscription.d.ts +8 -0
  35. package/dist/server/eventSubscription.js +27 -5
  36. package/dist/server/eventSubscription.js.map +1 -1
  37. package/dist/server/http.d.ts +46 -0
  38. package/dist/server/http.js +143 -28
  39. package/dist/server/http.js.map +1 -1
  40. package/dist/server/rateLimiter.d.ts +16 -1
  41. package/dist/server/rateLimiter.js +32 -11
  42. package/dist/server/rateLimiter.js.map +1 -1
  43. package/dist/server/stdio.js +5 -0
  44. package/dist/server/stdio.js.map +1 -1
  45. package/dist/server/stream.d.ts +25 -0
  46. package/dist/server/stream.js +53 -2
  47. package/dist/server/stream.js.map +1 -1
  48. package/dist/tools/common.d.ts +11 -10
  49. package/dist/tools/common.js +20 -2
  50. package/dist/tools/common.js.map +1 -1
  51. package/dist/tools/omada/blockClient.d.ts +3 -0
  52. package/dist/tools/omada/blockClient.js +14 -0
  53. package/dist/tools/omada/blockClient.js.map +1 -0
  54. package/dist/tools/omada/index.js +6 -0
  55. package/dist/tools/omada/index.js.map +1 -1
  56. package/dist/tools/omada/unblockClient.d.ts +3 -0
  57. package/dist/tools/omada/unblockClient.js +14 -0
  58. package/dist/tools/omada/unblockClient.js.map +1 -0
  59. package/dist/types/omada/clientBlockStatus.d.ts +11 -0
  60. package/dist/types/omada/clientBlockStatus.js +2 -0
  61. package/dist/types/omada/clientBlockStatus.js.map +1 -0
  62. package/dist/types/omada/index.d.ts +1 -0
  63. package/dist/utils/config-validations.d.ts +7 -0
  64. package/dist/utils/config-validations.js +17 -0
  65. package/dist/utils/config-validations.js.map +1 -1
  66. package/dist/utils/jwt.d.ts +15 -1
  67. package/dist/utils/jwt.js +29 -4
  68. package/dist/utils/jwt.js.map +1 -1
  69. package/dist/utils/sanitizeError.d.ts +1 -0
  70. package/dist/utils/sanitizeError.js +18 -0
  71. package/dist/utils/sanitizeError.js.map +1 -0
  72. package/dist/utils/tlsDispatcher.d.ts +34 -0
  73. package/dist/utils/tlsDispatcher.js +33 -0
  74. package/dist/utils/tlsDispatcher.js.map +1 -0
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/docker-compose.yml +4 -1
  78. package/docs/CHANGELOG.md +67 -0
  79. package/openapi.json +1 -1
  80. package/package.json +4 -3
  81. package/src/config.ts +83 -3
  82. package/src/haClient/automations.ts +2 -2
  83. package/src/haClient/calendars.ts +1 -1
  84. package/src/haClient/request.ts +23 -11
  85. package/src/haClient/services.ts +1 -1
  86. package/src/haClient/states.ts +1 -1
  87. package/src/omadaClient/auth.ts +20 -28
  88. package/src/omadaClient/client.ts +34 -0
  89. package/src/omadaClient/index.ts +10 -0
  90. package/src/omadaClient/request.ts +26 -25
  91. package/src/server/auth.ts +41 -7
  92. package/src/server/eventSubscription.ts +30 -5
  93. package/src/server/http.ts +165 -29
  94. package/src/server/rateLimiter.ts +43 -11
  95. package/src/server/stdio.ts +6 -0
  96. package/src/server/stream.ts +65 -2
  97. package/src/tools/common.ts +23 -2
  98. package/src/tools/omada/blockClient.ts +26 -0
  99. package/src/tools/omada/index.ts +7 -0
  100. package/src/tools/omada/unblockClient.ts +26 -0
  101. package/src/types/omada/clientBlockStatus.ts +11 -0
  102. package/src/types/omada/index.ts +1 -0
  103. package/src/utils/config-validations.ts +15 -0
  104. package/src/utils/jwt.ts +47 -4
  105. package/src/utils/sanitizeError.ts +18 -0
  106. package/src/utils/tlsDispatcher.ts +40 -0
  107. package/src/version.ts +1 -1
  108. package/tests/bdd/configuration.spec.ts +3 -0
  109. package/tests/config.test.ts +3 -0
  110. package/tests/resources/index.test.ts +3 -3
  111. package/tests/security/accessControl.test.ts +223 -0
  112. package/tests/security/authentication.test.ts +117 -0
  113. package/tests/security/configuration.test.ts +170 -0
  114. package/tests/security/cryptoTransport.test.ts +136 -0
  115. package/tests/security/dependencies.test.ts +152 -0
  116. package/tests/security/injection.test.ts +117 -0
  117. package/tests/security/insecureDesign.test.ts +122 -0
  118. package/tests/security/logging.test.ts +131 -0
  119. package/tests/security/resourceConsumption.test.ts +133 -0
  120. package/tests/tools/ai/analyzeSensors.test.ts +3 -3
  121. package/tests/tools/common.test.ts +5 -4
  122. package/tests/tools/homeassistant/handlers.test.ts +2 -2
  123. package/tests/tools/omada/handlers.test.ts +55 -3
package/.env.example CHANGED
@@ -13,9 +13,9 @@ HA_URL=http://homeassistant.10.0.0.19.nip.io:8123
13
13
  # Get from: Profile -> Long-Lived Access Tokens
14
14
  HA_TOKEN=your_long_lived_access_token_here
15
15
 
16
- # Validate SSL certificates (set to false for self-signed certs or HTTP)
17
- # Default: true
18
- HA_STRICT_SSL=false
16
+ # Validate SSL certificates (set to false ONLY for self-signed certs or plain HTTP)
17
+ # Default: true — keep true in production.
18
+ HA_STRICT_SSL=true
19
19
 
20
20
  # Request timeout in milliseconds
21
21
  # Default: 30000 (30 seconds)
@@ -93,33 +93,69 @@ MCP_HTTP_HEALTHCHECK_PATH=/health
93
93
  # Default: true
94
94
  MCP_HTTP_ALLOW_CORS=true
95
95
 
96
- # Allowed origins (comma-separated)
96
+ # Allowed origins (comma-separated) — used for CORS / Origin validation.
97
97
  # Use * for all origins (development only!)
98
98
  # Default: 127.0.0.1,localhost
99
99
  MCP_HTTP_ALLOWED_ORIGINS=127.0.0.1,localhost,http://mcpserver.10.0.0.18.nip.io:3000
100
100
 
101
+ # Allowed Host headers (comma-separated host[:port]) — OPT-IN DNS-rebinding
102
+ # protection. Leave UNSET (default) and Host validation is OFF — the server
103
+ # accepts any Host header and works everywhere, which is the safe choice for
104
+ # most deployments since the bearer token is the real boundary.
105
+ # Set it ONLY if you want to pin the exact hostnames the server is reached by.
106
+ # IMPORTANT: list the address CLIENTS DIAL to reach THIS server (its own LAN
107
+ # IP:port and/or its public/Cloudflare domain) — NOT your CORS origins, which are
108
+ # usually different addresses. Loopback/bind host:port is always added for you.
109
+ # Any Host header not listed here is rejected with 403.
110
+ # Example (LAN IP + public domain):
111
+ # MCP_HTTP_ALLOWED_HOSTS=192.168.0.18:3000,mcp.example.com
112
+
113
+ # -----------------------------------------------------------------------------
114
+ # Rate Limiting (only when MCP_SERVER_USE_HTTP=true)
115
+ # -----------------------------------------------------------------------------
116
+
117
+ # Enable per-client rate limiting. Default: true
118
+ MCP_RATE_LIMIT_ENABLED=true
119
+ # Window (ms) and max requests per window. Defaults: 60000 / 100
120
+ # MCP_RATE_LIMIT_WINDOW_MS=60000
121
+ # MCP_RATE_LIMIT_MAX_REQUESTS=100
122
+
123
+ # Trusted proxy IPs (comma-separated). Forwarding headers (CF-Connecting-IP,
124
+ # X-Forwarded-For, X-Real-IP) are honored ONLY when the immediate peer is listed
125
+ # here; otherwise the socket address is used so forged headers cannot evade limits.
126
+ # Behind Cloudflare/Docker, set this to the gateway/proxy address that connects to
127
+ # the server (e.g. the Docker bridge gateway). Leave empty to key purely on socket.
128
+ # MCP_RATE_LIMIT_TRUSTED_PROXIES=172.17.0.1
129
+
101
130
  # -----------------------------------------------------------------------------
102
131
  # Authentication Configuration (only when MCP_SERVER_USE_HTTP=true)
103
132
  # -----------------------------------------------------------------------------
104
133
 
105
134
  # Authentication method: none or bearer
106
- # - none: No authentication required
107
- # - bearer: Require Bearer token in Authorization header
108
- # Default: none
109
- MCP_AUTH_METHOD=none
110
-
111
- # Authentication tokens (comma-separated for multiple clients)
112
- # Required when MCP_AUTH_METHOD=bearer
113
- # Example: MCP_AUTH_TOKEN=token1,token2,token3
114
- # MCP_AUTH_TOKEN=your-secret-token-here
135
+ # - none: No authentication. Only permitted on a loopback bind (127.0.0.1/::1).
136
+ # - bearer: Require a JWT Bearer token in the Authorization header.
137
+ # Default: bearer (secure default; the server refuses none on a public bind).
138
+ MCP_AUTH_METHOD=bearer
139
+
140
+ # JWT signing secret (HS256). REQUIRED when MCP_AUTH_METHOD=bearer.
141
+ # Must be at least 32 characters. Generate one with:
142
+ # node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
143
+ MCP_AUTH_SECRET=replace-with-a-random-secret-at-least-32-characters
144
+
145
+ # Optional JWT hardening (recommended for production):
146
+ # Require tokens to carry an expiry (exp) claim.
147
+ # MCP_AUTH_REQUIRE_EXP=true
148
+ # Reject tokens whose iss / aud claims do not match.
149
+ # MCP_AUTH_ISSUER=mcp-ha-connect
150
+ # MCP_AUTH_AUDIENCE=mcp-clients
115
151
 
116
152
  # =============================================================================
117
153
  # Security Notes
118
154
  # =============================================================================
119
155
  # - Never commit this file with real credentials
120
156
  # - Use HA_STRICT_SSL=true in production with HTTPS
121
- # - Restrict MCP_HTTP_ALLOWED_ORIGINS to trusted domains
157
+ # - Restrict MCP_HTTP_ALLOWED_ORIGINS to trusted origins (scheme://host:port)
122
158
  # - Use MCP_HTTP_BIND_ADDR=127.0.0.1 for local-only access
123
159
  # - Rotate your Home Assistant tokens periodically
124
- # - Enable MCP_AUTH_METHOD=bearer in production for API security
125
- # - Use unique tokens per client for better access control and audit
160
+ # - Keep MCP_AUTH_METHOD=bearer in production; none is loopback-only
161
+ # - Issue a distinct JWT per client (with scoped RBAC permissions) for audit
package/CLAUDE.md ADDED
@@ -0,0 +1,341 @@
1
+ # mcp-ha-connect -- Claude Code Agent
2
+
3
+ ## Identity & Scope
4
+
5
+ This agent owns the **mcp-ha-connect MCP server** — a Model Context Protocol server for
6
+ Home Assistant, TP-Link Omada, and Local AI integration.
7
+
8
+ **Repo:** `github.com/coffeerunhobby/mcp-ha-connect`
9
+ **npm:** `@coffeerunhobby/mcp-ha-connect`
10
+ **Docker:** `ghcr.io/coffeerunhobby/mcp-ha-connect`
11
+ **Live endpoint:** Cloudflare domain → NAS Docker port 3000 (domain in NAS `.env`)
12
+
13
+ **Working directory:** `C:\workspace\mcp` — this is the canonical copy.
14
+ `W:\mcp-homeassistant` is a stale export at v0.8.0 — ignore it.
15
+
16
+ ---
17
+
18
+ ## Stack
19
+
20
+ - **Runtime:** Node.js 20+ (dev machine uses nvm)
21
+ - **Language:** TypeScript, compiled to `dist/` via `tsc`
22
+ - **MCP SDK:** `@modelcontextprotocol/sdk`
23
+ - **Transport:** Streamable HTTP (port 3000, path `/mcp`)
24
+ - **Auth:** JWT bearer tokens with RBAC permission system
25
+ - **Plugins:** Home Assistant · TP-Link Omada SDN · Local AI (Ollama/OpenAI-compat)
26
+ - **Tests:** Vitest (829 unit tests) + integration tests in `tests/integration/`
27
+ - **Container:** Alpine Node image, deployed on Synology NAS via Docker Compose
28
+
29
+ ---
30
+
31
+ ## Infrastructure
32
+
33
+ | Resource | Address | Notes |
34
+ |---|---|---|
35
+ | **MCP server** | `mcpserver.10.0.0.18.nip.io:3000` | NAS Docker container |
36
+ | **Home Assistant** | `homeassistant.10.0.0.19.nip.io:8123` | |
37
+ | **Omada controller** | `omada.10.0.0.18.nip.io:8043` | HTTPS, self-signed cert |
38
+ | **Ollama** | `ollama.10.0.0.17.nip.io:11434` | model `phi4:14b` |
39
+ | **n8n** | `n8n.10.0.0.18.nip.io:5678` | also exposed via Cloudflare |
40
+ | **NAS Docker path** | `/volume1/docker/mcp-ha-connect/` | compose.yaml + .env |
41
+ | **Cloudflare** | proxies public domain → NAS port 3000 | domain stored in `.env` |
42
+
43
+ ---
44
+
45
+ ## Secrets & Credentials
46
+
47
+ All secrets live in the NAS `.env` file and as **Windows User environment variables**.
48
+ Never store token values in source files, commit messages, or this file.
49
+
50
+ | Variable | Purpose |
51
+ |---|---|
52
+ | `NPM_MCP_HA_TOKEN` | npm Granular token for publishing |
53
+ | `GHCR_TOKEN` | GitHub PAT with `write:packages` scope |
54
+
55
+ ### Retrieve for use
56
+
57
+ ```powershell
58
+ $npmToken = [Environment]::GetEnvironmentVariable('NPM_MCP_HA_TOKEN', 'User')
59
+ $ghcrToken = [Environment]::GetEnvironmentVariable('GHCR_TOKEN', 'User')
60
+ ```
61
+
62
+ ### npm token requirements
63
+
64
+ The npm token must be **Granular type** with:
65
+ - Scope `@coffeerunhobby` → **Read and write** (scope-level, not per-package)
66
+ - **Bypass two-factor authentication** ✅ checked
67
+
68
+ Create at: npmjs.com → Access Tokens → Generate New Token → Granular Access Token
69
+
70
+ **Critical:** Never run `npm logout` — it revokes the token server-side and deletes it
71
+ from npmjs.com. If the token disappears from the web UI, that's why.
72
+
73
+ ---
74
+
75
+ ## Release Process
76
+
77
+ ### 1. Pre-flight
78
+
79
+ ```powershell
80
+ cd C:\workspace\mcp
81
+ npm test # all tests must pass
82
+ npm run build # must compile clean
83
+ npm run lint # ZERO errors AND zero warnings (see Code Quality policy)
84
+ ```
85
+
86
+ ### 2. Version bump
87
+
88
+ Update **both** files to the new version:
89
+ - `package.json` → `"version": "X.Y.Z"`
90
+ - `src/version.ts` → `export const VERSION = 'X.Y.Z';`
91
+
92
+ Update `docs/CHANGELOG.md` with the new version section.
93
+
94
+ ### 3. Commit & tag
95
+
96
+ ```powershell
97
+ git add .
98
+ git commit -m "vX.Y.Z - <short description>"
99
+ git tag vX.Y.Z
100
+ git push origin main
101
+ git push origin vX.Y.Z
102
+ ```
103
+
104
+ Create the GitHub Release at:
105
+ `https://github.com/coffeerunhobby/mcp-ha-connect/releases/new?tag=vX.Y.Z`
106
+
107
+ ### 4. npm publish
108
+
109
+ **Preferred: interactive login (browser).** Granular tokens keep getting revoked, so the
110
+ reliable path is an interactive `npm login`, which opens the browser for auth. The agent
111
+ **cannot** complete this alone — it MUST ask the user to run/confirm the browser login,
112
+ then publish together:
113
+
114
+ ```powershell
115
+ cd C:\workspace\mcp
116
+ npm login # opens browser — USER completes this, agent waits
117
+ npm whoami # must print the account name, not 403
118
+ npm publish --access public
119
+ ```
120
+
121
+ **Do NOT run `npm logout`** afterward — it revokes any granular token server-side.
122
+
123
+ > Agent rule: when a release reaches this step, STOP and ask the user to help with
124
+ > `npm login` and `npm publish --access public` (browser-based). Do not silently fall
125
+ > back to a stored token — if `npm whoami` returns 403, the token is dead.
126
+
127
+ ### 5. Docker build & push
128
+
129
+ Docker Desktop must be running.
130
+
131
+ ```powershell
132
+ $ghcrToken = [Environment]::GetEnvironmentVariable('GHCR_TOKEN', 'User')
133
+ $ghcrToken | docker login ghcr.io -u coffeerunhobby --password-stdin
134
+
135
+ cd C:\workspace\mcp
136
+ docker build --no-cache `
137
+ -t ghcr.io/coffeerunhobby/mcp-ha-connect:X.Y.Z `
138
+ -t ghcr.io/coffeerunhobby/mcp-ha-connect:latest .
139
+
140
+ docker push ghcr.io/coffeerunhobby/mcp-ha-connect:X.Y.Z
141
+ docker push ghcr.io/coffeerunhobby/mcp-ha-connect:latest
142
+ ```
143
+
144
+ Use `docker build`, not `docker buildx build --platform`. The NAS is amd64 and a plain
145
+ build on a Windows/amd64 host produces the correct image.
146
+
147
+ ### 6. Deploy to NAS
148
+
149
+ ```bash
150
+ cd /volume1/docker/mcp-ha-connect
151
+ docker compose pull && docker compose up -d
152
+ docker compose logs --tail=30
153
+ ```
154
+
155
+ ---
156
+
157
+ ## NAS `.env` — critical settings
158
+
159
+ ```env
160
+ MCP_HTTP_BIND_ADDR=0.0.0.0
161
+ ```
162
+
163
+ Must be `0.0.0.0`, not `127.0.0.1`. Docker port mapping requires the server to listen on
164
+ all interfaces inside the container — loopback only means Cloudflare gets a 502.
165
+
166
+ ```env
167
+ MCP_HTTP_ALLOWED_ORIGINS=...,<cloudflare-domain>,https://<cloudflare-domain>
168
+ ```
169
+
170
+ Must include both the bare hostname AND the `https://` form of the Cloudflare domain.
171
+ The MCP SDK checks the `host` header (no protocol) for DNS rebinding protection AND
172
+ the `origin` header (with protocol) for CORS.
173
+
174
+ ---
175
+
176
+ ## Omada SDN — known issues
177
+
178
+ **After upgrading the Omada controller version**, the `OMADA_OMADAC_ID` changes.
179
+ Error code `-44106` is returned for BOTH wrong credentials AND wrong `OMADA_OMADAC_ID` —
180
+ the API does not distinguish between them.
181
+
182
+ **To get the new Omada ID:**
183
+ Omada web UI → Settings → Open API → click the eye icon → copy **Omada ID**.
184
+
185
+ ```bash
186
+ sed -i 's/OMADA_OMADAC_ID=.*/OMADA_OMADAC_ID=<new-id>/' /volume1/docker/mcp-ha-connect/.env
187
+ docker compose up -d
188
+ ```
189
+
190
+ If Omada is unreachable or credentials are wrong, the server **gracefully degrades** —
191
+ Omada tools are disabled but HA and AI keep working. Set `OMADA_PLUGIN_ENABLED=false`
192
+ to disable Omada entirely while troubleshooting.
193
+
194
+ ---
195
+
196
+ ## Code Quality — fix every error and warning
197
+
198
+ **Always fix errors AND warnings. Never label something "pre-existing debt" and move on.**
199
+
200
+ - `npm run lint`, `npm run build`, and `npm test` must all be **completely clean** —
201
+ zero errors, zero warnings — before a change is considered done.
202
+ - If you touch a file (or a release surfaces lint output) and find existing errors or
203
+ warnings, they are now yours to fix. "It was already broken" is not an acceptable
204
+ reason to leave it.
205
+ - **Exception — release timing:** do not cut a patch release *solely* to clean up lint.
206
+ If quality issues are found right after a release has shipped, fold the fixes into the
207
+ **next feature's** commit rather than churning a `vX.Y.(Z+1)`. Outstanding cleanup of
208
+ this kind is tracked in `.claude/NEXT-SESSION.md` so it isn't forgotten.
209
+
210
+ > Currently outstanding: `Function`-type lint warnings in the test mock helpers
211
+ > (`tests/tools/**/handlers.test.ts`). Fix these as part of the next feature change —
212
+ > NOT as a standalone patch release.
213
+
214
+ ---
215
+
216
+ ## Development
217
+
218
+ ```powershell
219
+ cd C:\workspace\mcp
220
+ npm run dev # tsx watch with .env
221
+ npm test # vitest (excludes integration)
222
+ npm run test:watch # watch mode
223
+ npm run lint # eslint
224
+ npm run build # tsc compile to dist/
225
+ npm run generate:jwt # generate a test JWT token
226
+ ```
227
+
228
+ ### Integration tests (requires live .env)
229
+
230
+ ```powershell
231
+ npm run test:integration
232
+ ```
233
+
234
+ ---
235
+
236
+ ## Never assume. Always verify.
237
+
238
+ - **Cloudflare 502**: check the container is running (`docker compose ps`) and
239
+ `MCP_HTTP_BIND_ADDR=0.0.0.0` is set.
240
+ - **MCP client connects but tools fail**: check `MCP_HTTP_ALLOWED_ORIGINS` includes
241
+ the Cloudflare domain in both bare and `https://` form.
242
+ - **Claude Desktop shows "Server disconnected" but `/health` is 200**: the client's
243
+ mcp-remote auth header is malformed. A `"Authorization: Bearer ${MCP_AUTH_TOKEN}"`
244
+ placeholder does NOT expand on Windows (`cmd /c` uses `%VAR%`, mcp-remote doesn't
245
+ substitute `${VAR}`) → literal text is sent → server 401 → mcp-remote drops into a
246
+ failing OAuth flow. Fix: inline the literal JWT in `claude_desktop_config.json`'s
247
+ `--header` arg, and add `-y` to `npx` so first run doesn't hang. Verify the server
248
+ side first with a direct `curl` POST to `/mcp` carrying the token.
249
+ - **npm publish returns 404 on PUT**: token likely has per-package scope instead of
250
+ scope-level access, or "Bypass 2FA" is not ticked. Delete token and recreate.
251
+ - **Omada auth error -44106**: check `OMADA_OMADAC_ID` first, not just credentials —
252
+ this error code covers both cases.
253
+ - **Before a release**: always run `npm test` and `npm run build` clean.
254
+ - **On release, bump the version everywhere**: `package.json`, `src/version.ts`,
255
+ `docs/CHANGELOG.md`, AND `openapi.json` (`info.version`) — the last one drifts easily.
256
+
257
+ ---
258
+
259
+ ## Known gaps
260
+
261
+ - **`MCP_TOOL_REGISTRATION_MODE` is NOT implemented.** Lazy/meta-tool registration
262
+ (`list_tools` + `execute` instead of all ~60 tool schemas, to save context on small
263
+ models) was designed but never built — `config.ts` doesn't parse the var and there is
264
+ no `src/tools/registry.ts` / `metaTools.ts` / `allMetadata.ts`. Setting it in `.env`
265
+ is a silent no-op; the server always registers every tool eagerly. Either build the
266
+ feature or drop the var from the NAS `.env`.
267
+
268
+ ---
269
+
270
+ ## Security
271
+
272
+ This server controls real devices (HA, Omada) and is exposed publicly via Cloudflare, so
273
+ treat it as security-sensitive. A full audit (2026-06-07) with file/line findings is in
274
+ `.claude/NEXT-SESSION.md`; this section is the **durable policy** distilled from it.
275
+
276
+ ### Dependencies — update, don't rewrite
277
+
278
+ - Runtime deps are intentionally tiny: `@modelcontextprotocol/sdk`, `ws`, `zod`.
279
+ **Do not hand-roll replacements** for these (or for transitive libs like `hono`,
280
+ `ajv`, `path-to-regexp`). A homegrown version loses community review and adds more
281
+ bugs than it removes. Zod and the MCP SDK earn their keep.
282
+ - The alarming `npm audit` list (`hono`, `@hono/node-server`, `fast-uri`, `ajv`,
283
+ `path-to-regexp`, `qs`) is **entirely transitive under the SDK**:
284
+ `@modelcontextprotocol/sdk → @hono/node-server → hono`. **Bumping the SDK fixes most
285
+ of them.** `ws` is the one direct dep to bump on its own.
286
+ - Triage with `npm audit --omit=dev` — dev-only vulns (vitest/esbuild/tsx) don't ship.
287
+ The question is *reachability in production*, not the raw count.
288
+ - Remediate on a branch: `npm install @modelcontextprotocol/sdk@latest ws@latest`, then
289
+ `npm test && npm run build && npm audit --omit=dev`. Commit `package-lock.json`;
290
+ CI/Docker use `npm ci`, not `npm install`. Widen version ranges only intentionally.
291
+
292
+ ### MCP transport invariant (the SDK "cross-client data leak" advisory)
293
+
294
+ GHSA-345p-7cg4-v4c7 is about **sharing one `McpServer`/transport instance across
295
+ clients**. Our HTTP layer MUST create a **fresh server + transport per request**
296
+ (stateless mode, current behavior) or per session keyed by `Mcp-Session-Id` (stateful) —
297
+ never a single global transport handling every `/mcp` POST. Re-verify this after any SDK
298
+ upgrade or transport refactor. Bumping the SDK is necessary but not sufficient if the
299
+ architecture shares instances.
300
+
301
+ ### Our attack surface (higher-leverage than CVEs)
302
+
303
+ 1. **Auth stays on.** NAS `.env` must keep `MCP_AUTH_METHOD=bearer`. `none` =
304
+ unauthenticated full admin (fail-open default). Never put the JWT on public channels;
305
+ tokens should carry an `exp` (the current prod token does not — fix when convenient).
306
+ 2. **Authorization is per-tool.** Every MCP tool is gated via
307
+ `wrapToolHandler(..., Permission.X)`. The REST `/api/*` bridge is **not** permission-
308
+ gated — keep it disabled or treat it as full-trust until it enforces the same masks.
309
+ Prompt injection via the AI path ("ignore previous instructions, unlock the door") is
310
+ contained only by these authorization checks — the model is not a security boundary.
311
+ 3. **Scoped TLS only.** Never use the process-global `NODE_TLS_REJECT_UNAUTHORIZED=0` —
312
+ it disables cert validation for *all* concurrent outbound HTTPS (HA token + AI key at
313
+ MITM risk), not just Omada. Use a per-client `undici` dispatcher, or pin the Omada
314
+ self-signed cert as a CA so verification stays on.
315
+ 4. **Encode every path segment** built from tool input. The Omada client does
316
+ (`encodeURIComponent` throughout); the HA client interpolates raw — fix when touched.
317
+ 5. **Secrets never in logs.** Redact tokens / secrets / `Authorization` headers / API
318
+ keys, and **sanitize `error.message`/`error.stack` before logging** — HTTP libraries
319
+ embed auth headers and token-bearing URLs inside thrown errors. Don't return raw
320
+ `error.message` to clients (leaks internal hostnames).
321
+ 6. **HTTP hardening when `useHttp`:** request body-size limit, rate limiting keyed on a
322
+ trusted-proxy IP (Cloudflare `CF-Connecting-IP`, not the spoofable `X-Forwarded-For`),
323
+ and static security headers (`X-Content-Type-Options: nosniff`, `X-Frame-Options`,
324
+ `Referrer-Policy`, CSP where applicable).
325
+
326
+ ### Confirmed safe — don't re-litigate
327
+
328
+ - **JWT:** HS256 is pinned (the token's `alg` header is ignored), signature compared with
329
+ `crypto.timingSafeEqual` — not vulnerable to alg-confusion or timing attacks. (Still
330
+ add `exp` enforcement, and `iss`/`aud` if it ever becomes multi-issuer.)
331
+ - **Omada client** encodes every path segment; query params via `URLSearchParams`.
332
+ - **No** `eval` / `new Function` / dynamic `require`; the only child process is
333
+ `spawn('node', [fixedPath])` — no shell, no user-controlled argv.
334
+ - **No arbitrary-URL SSRF via tool args** — HA/Omada/AI base URLs come only from config.
335
+ - **Git hygiene clean** — `.env` is never tracked; no hardcoded real secrets in src/tests.
336
+
337
+ ### Insecure defaults to flip when next touched
338
+
339
+ `config.ts` defaults `MCP_AUTH_METHOD=none`; `.env.example` ships `none` +
340
+ `HA_STRICT_SSL=false`; `docker-compose.yml` hardcodes `MCP_HTTP_ALLOWED_ORIGINS=*`.
341
+ None of these reflect the (secure) live deployment — make the secure values the defaults.
package/Dockerfile CHANGED
@@ -28,4 +28,9 @@ RUN apk add --no-cache curl
28
28
  COPY package*.json ./
29
29
  RUN npm install --omit=dev
30
30
  COPY --from=build /app/dist ./dist
31
+
32
+ # M10: drop root. The official node image ships a non-root `node` user (uid 1000).
33
+ # Built artifacts under /app are world-readable, so no chown is required; the
34
+ # server listens on 3000 (>1024) which needs no privileged capability.
35
+ USER node
31
36
  CMD ["node", "--experimental-quic", "dist/index.js"]
package/README.md CHANGED
@@ -550,24 +550,40 @@ Use the HTTP transport with your MCP-compatible web client:
550
550
  3. Set transport to Streamable HTTP (MCP 2025-03-26)
551
551
  4. Enable CORS by adding your origin to `MCP_HTTP_ALLOWED_ORIGINS`
552
552
 
553
- ### With Remote MCP Proxy (uvx)
553
+ ### With Remote MCP Proxy (mcp-remote)
554
554
 
555
- Connect to a remote MCP server using mcp-remote:
555
+ Connect a stdio-only client (Claude Desktop, LM Studio) to a remote HTTP MCP
556
+ server using [`mcp-remote`](https://www.npmjs.com/package/mcp-remote). On Windows,
557
+ launch it through `cmd /c` and include `-y` so the first run never blocks on the
558
+ npx install prompt:
556
559
 
557
560
  ```json
558
561
  {
559
562
  "mcpServers": {
560
563
  "homeassistant": {
561
- "command": "uvx",
564
+ "command": "cmd",
562
565
  "args": [
566
+ "/c",
567
+ "npx",
568
+ "-y",
563
569
  "mcp-remote",
564
- "http://your-server:3000/mcp"
570
+ "https://your-server.example/mcp",
571
+ "--header",
572
+ "Authorization: Bearer <your-jwt-token>"
565
573
  ]
566
574
  }
567
575
  }
568
576
  }
569
577
  ```
570
578
 
579
+ > ⚠️ **Do not use `${VAR}` placeholders in the `--header` value.** A config like
580
+ > `"Authorization: Bearer ${MCP_AUTH_TOKEN}"` with a matching `env` block does **not**
581
+ > work on Windows: `cmd /c` only expands `%VAR%` (not `${VAR}`), and mcp-remote does
582
+ > not substitute it either. The literal text `${MCP_AUTH_TOKEN}` is sent as the token,
583
+ > the server replies `401`, and mcp-remote then falls into a failing OAuth flow that
584
+ > surfaces as **"Server disconnected"**. Inline the actual JWT instead. (On macOS/Linux
585
+ > you can instead let your shell expand the variable before the client launches.)
586
+
571
587
  ## Network Configuration Notes
572
588
 
573
589
  - **`host.docker.internal`** - Use this to access services on your host machine from Docker
@@ -637,6 +653,25 @@ docker run --rm -i \
637
653
  ghcr.io/coffeerunhobby/mcp-ha-connect:latest
638
654
  ```
639
655
 
656
+ ### mcp-remote Shows "Server disconnected" (401)
657
+
658
+ If `/health` returns `200` but the client shows **"Server disconnected"**, the
659
+ auth header is almost certainly malformed. Most common cause: a `${VAR}`
660
+ placeholder in the `--header` value that never got expanded (see the warning under
661
+ [With Remote MCP Proxy](#with-remote-mcp-proxy-mcp-remote)). Verify the endpoint
662
+ accepts your token directly:
663
+
664
+ ```bash
665
+ curl -i -X POST https://your-server.example/mcp \
666
+ -H "Authorization: Bearer <your-jwt-token>" \
667
+ -H "Content-Type: application/json" \
668
+ -H "Accept: application/json, text/event-stream" \
669
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"diag","version":"1.0"}}}'
670
+ ```
671
+
672
+ A `200` with an `initialize` result means the server and token are fine — fix the
673
+ client config (inline the literal token). A `401` means the token itself is wrong.
674
+
640
675
  ### Docker `host.docker.internal` Not Working (Linux)
641
676
 
642
677
  Add host gateway:
package/dist/config.d.ts CHANGED
@@ -31,13 +31,23 @@ export interface EnvironmentConfig {
31
31
  httpHealthcheckPath?: string;
32
32
  httpAllowCors: boolean;
33
33
  httpAllowedOrigins?: string[];
34
+ /**
35
+ * Opt-in list of Host-header values (host[:port]) the server is reached by.
36
+ * When set, DNS-rebinding Host validation is enforced at the transport (M3);
37
+ * when unset, Host validation is OFF (origin/CORS is handled separately).
38
+ */
39
+ httpAllowedHosts?: string[];
34
40
  sseEventsEnabled: boolean;
35
41
  sseEventsPath: string;
36
42
  rateLimitEnabled: boolean;
37
43
  rateLimitWindowMs: number;
38
44
  rateLimitMaxRequests: number;
45
+ rateLimitTrustedProxies?: string[];
39
46
  authMethod: 'none' | 'bearer';
40
47
  authSecret?: string;
48
+ authRequireExp: boolean;
49
+ authIssuer?: string;
50
+ authAudience?: string;
41
51
  permissions: PermissionsConfig;
42
52
  }
43
53
  export declare function loadConfig(env?: NodeJS.ProcessEnv): EnvironmentConfig;