Skip to content

Commit 62305b7

Browse files
fix: add-on auth-error guidance + webhook proxy diagnosability (#1694) (#1700)
* fix: add-on auth-error guidance + webhook proxy diagnosability (#1694) #1694 reported authentication failures on a Home Assistant add-on install. The reported errors and logs aren't internally consistent (the add-on log shows the WebSocket authenticating then disconnecting; home-assistant.log shows "invalid authentication from localhost" on /api/websocket plus an InsecureKeyLengthWarning), and the cause was never reproduced or confirmed. This does not claim to fix that — it makes the failure class easier to diagnose and report, and folds in webhook-proxy diagnosability items raised in the same thread. - errors.py: on add-on installs only (SUPERVISOR_TOKEN present), create_auth_error drops the HOMEASSISTANT_TOKEN / long-lived-token suggestions (no such token under Supervisor auth) for add-on-appropriate guidance. stdio/pip/Docker installs unchanged. Asserts no cause. - ha_report_issue: capture home-assistant.log in the bug report (over the REST/Supervisor path, through the existing secret-sanitizer). The decisive lines only surface there, not in the add-on container log. - webhook proxy: mirror inbound-request debug lines into the add-on's own log (capped /config/.mcp_proxy_inbound.log, tailed by the add-on, fire-and-forget). - webhook proxy: handle SIGTERM/SIGINT so a Supervisor stop logs why it exited and runs cleanup (previously skipped — the process was killed mid-loop). - webhook proxy: append a "fully restart Home Assistant" hint to every error the proxy returns, including the browser "invalid client_id" OAuth page — OAuth/webhook registration only refreshes on a full HA restart. - DOCS + site FAQ: document that reinstalling the add-on regenerates the webhook URL (wipes /data, overwrites the config) and that toggling OAuth or regenerating credentials needs a full HA restart; recreate the Claude.ai connector when the URL or OAuth changes. - Bump webhook proxy 1.2.0 -> 1.2.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: address review feedback on #1694 webhook-proxy changes Toolkit + Gemini review of #1700: - Serialize inbound-mirror writes with a threading.Lock — the cap's read-modify-write trim could interleave under HA's executor pool (Gemini + silent-failure-hunter + code-reviewer). - Reset the stored tail offset on truncation so a newline-less truncated mirror isn't re-read from 0 every poll (Gemini). - Guard the initial tail-offset stat() so a permission error / racing delete can't crash startup (Gemini). - Extract _install_shutdown_handlers / _shutdown_cleanup / _initial_tail_offset from main(); cleanup now restores default signal handling first so a second signal can't abort it. Add unit coverage for the SIGTERM -> cleanup contract and the webhook 502/500 restart-hint (pr-test-analyzer + code-reviewer). - Add a negative test that an empty core error log omits the report section. - Scope the "needs full HA restart" comments to the OAuth HTTP views (the webhook itself is re-registered on reload), and soften the add-on auth suggestion + error-log docstring so they don't assert an unconfirmed cause (comment-analyzer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cover core_error_log in the in-addon ha_report_issue e2e The real-Supervisor e2e (TestBugReportAddonLogsReal) asserted addon_logs but not the sibling core_error_log field this PR adds. Add the parallel assertion so the #1694 home-assistant.log capture is exercised end-to-end over the real Supervisor-routed get_error_log path, not only in mocked unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: bump container/haos skip ceilings for the new inaddon e2e The new TestBugReportAddonLogsReal.test_core_error_log_in_report is @inaddon_only (module-level pytestmark), so it skips on the container and external-haos lanes and runs only on haos_inaddon — same shape as the read-only inaddon test master just added. Bump both ceilings by one so the skip-count guard in test_session_skipped_count_below_ceiling stays green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: scope the restart hint to OAuth registration errors (patch76 review) A full HA restart only unsticks the stale-OAuth-registration case, so the hint no longer goes on client-side protocol errors (invalid_grant / invalid_request / unsupported_grant_type) or the webhook 502/500 paths — only on invalid_client and the browser "invalid client_id" page, via a restart_hint flag on _text_error / _json_error. Side effect: __init__.py's 502/500 no longer need RESTART_HINT, so it is now single-sourced in oauth.py — eliminating the cross-file duplication patch76 flagged (and dropping its drift-guard test). Also tighten the DOCS/FAQ/CHANGELOG "can't re-register on reload" wording: only the OAuth HTTP views need the full restart; the webhook itself re-registers on reload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2e4d548 commit 62305b7

15 files changed

Lines changed: 881 additions & 75 deletions

File tree

homeassistant-addon-webhook-proxy/CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,38 @@
33
<!-- version list -->
44

55

6+
## v1.2.1 (2026-06-28)
7+
8+
### Added
9+
10+
- Mirror inbound-request debug lines into the addon's own log. When "Log
11+
inbound requests" is on, the lines that were previously only visible in the
12+
Home Assistant log (Settings → System → Logs) now also appear on the addon's
13+
Log tab, so you can confirm a client is reaching the server without leaving
14+
the addon page.
15+
16+
### Fixed
17+
18+
- Log a shutdown reason and run cleanup on a Supervisor stop. The addon now
19+
handles `SIGTERM`/`SIGINT`, so stopping it unregisters the webhook (as the
20+
docs describe) and records why it exited, instead of being killed mid-loop
21+
with no log line and the webhook left registered.
22+
23+
- Append a "fully restart Home Assistant" hint to the OAuth stale-registration
24+
errors (`invalid_client` and the browser "Invalid client id" page). The OAuth
25+
HTTP views only refresh on a full HA restart, so a regenerate / OAuth toggle /
26+
reinstall can otherwise leave a stale error with no obvious fix. (Client-side
27+
protocol errors and the upstream 502/500 paths don't get the hint — a restart
28+
isn't the fix there.)
29+
30+
### Documentation
31+
32+
- Warn that the Claude.ai connector must be deleted and re-created when OAuth
33+
is toggled on/off or the webhook URL changes — Claude.ai caches the
34+
authentication mode and URL per connector, so reusing the old one fails (for
35+
example `invalid client id` on the consent page).
36+
37+
638
## v1.2.0 (2026-06-15)
739

840
### Added

homeassistant-addon-webhook-proxy/DOCS.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ This addon enables remote access to your HA MCP Server through any reverse proxy
3131

3232
> **Reachability check:** Claude.ai connects from Anthropic's servers, not from your computer — so the URL must be reachable from the public internet, not just your LAN. If a connection won't establish, open the remote URL on your **phone with Wi-Fi turned off** (cellular only). If it doesn't load there, the URL isn't publicly reachable (a DNS, port-forward, TLS, or reverse-proxy problem) and Claude.ai can't reach it either — fix that first.
3333
34+
> **Recreate the connector when OAuth or the URL changes.** Claude.ai binds an
35+
> authentication mode to a connector when you add it, and caches it. If you
36+
> later **turn OAuth on or off**, or the **webhook URL changes** (you rotated
37+
> it, or reinstalled the addon — which generates a new URL), the existing
38+
> connector keeps using the old mode/URL and tool calls fail (often
39+
> `invalid client id` on the consent page, or a silently dead endpoint).
40+
> **Delete the connector in Claude.ai and add a fresh one** with the current
41+
> URL (and current OAuth Client ID/Secret, if OAuth is on). This is required
42+
> even when going from OAuth on → off.
43+
3444
> **Note:** If something doesn't seem to work after restarting HA, try restarting the addon as well.
3545
3646
## Configuration
@@ -181,7 +191,7 @@ What stops an attacker who can reach the consent page from gaining access:
181191

182192
If a client (e.g. Claude.ai) can't connect and you can't tell whether its requests are even reaching Home Assistant, turn on **Log inbound requests** (the `debug_logging` option on the main Configuration page) and **restart the addon**.
183193

184-
When it's on, every request that hits the webhook is logged to the **Home Assistant log** *not* this addon's log, because requests reach Home Assistant directly rather than passing through the addon process. View them at **Settings → System → Logs** (or filter for `mcp_proxy`). Each line shows the method, a masked webhook path, the source address, whether an `Authorization` header was present, and the upstream response status:
194+
When it's on, every request that hits the webhook is logged to the **Home Assistant log** (requests reach Home Assistant directly rather than passing through the addon process). View them at **Settings → System → Logs** (or filter for `mcp_proxy`). The same lines are also **mirrored into this addon's own log**, so you can watch them on the addon's Log tab without leaving the addon page. Each line shows the method, a masked webhook path, the source address, whether an `Authorization` header was present, and the upstream response status:
185195

186196
```
187197
MCP Proxy [inbound]: POST /api/webhook/mcp_3e... from 203.0.113.4 (Authorization header: present)
@@ -230,6 +240,21 @@ If the `mcp_proxy` integration doesn't appear in Settings > Devices & Services:
230240
1. Restart Home Assistant (Settings > System > Restart)
231241
2. The addon will start automatically and retry setup
232242

243+
### Persistent errors, especially OAuth "Invalid client id"
244+
245+
If the proxy keeps returning the same error — most notably **"Invalid client id"**
246+
on the OAuth consent page even though you pasted the correct Client ID — **fully
247+
restart Home Assistant** (Settings → System → Restart).
248+
249+
The OAuth provider's HTTP views are bound into Home Assistant's HTTP layer when the
250+
integration first loads, and Home Assistant can't re-register or drop them on a
251+
config reload. So changes that come from **toggling OAuth on/off, regenerating
252+
credentials, or reinstalling the add-on** don't take effect until a full HA
253+
restart — *reloading the integration or restarting the add-on is not enough.* (The
254+
webhook endpoint itself is re-registered on every reload, so it's specifically the
255+
OAuth views that need the restart.) After the restart, re-add the Claude.ai
256+
connector with the current URL (and current Client ID/Secret, if OAuth is on).
257+
233258
### Claude.ai says "Couldn't reach the MCP server"
234259

235260
Two cases:
@@ -242,10 +267,12 @@ Two cases:
242267
## Disabling / Uninstalling
243268

244269
- **Stopping** the addon is safe — the webhook URL stays the same and resumes working when the addon is restarted
270+
- **Reinstalling** the addon always changes the webhook URL. Uninstalling wipes the addon's `/data` (where `webhook_id.txt` is stored), so the next start generates a fresh webhook id and overwrites `/config/.mcp_proxy_config.json` with it. Update your MCP client (and re-add the Claude.ai connector) with the new URL afterwards.
245271
- **Uninstalling** the addon does not automatically remove the custom integration files. To fully clean up after uninstalling:
246272
1. Delete `/config/custom_components/mcp_proxy/`
247273
2. Delete `/config/.mcp_proxy_config.json`
248-
3. Restart Home Assistant
274+
3. Delete `/config/.mcp_proxy_inbound.log` (only present if you used **Log inbound requests**; normally removed when the addon stops)
275+
4. Restart Home Assistant
249276

250277
## Support
251278

homeassistant-addon-webhook-proxy/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: "Nabu Casa / Webhook Proxy for HA MCP"
22
description: "Remote access proxy via Nabu Casa or any reverse proxy (Cloudflare, DuckDNS, nginx)"
3-
version: "1.2.0"
3+
version: "1.2.1"
44
slug: "ha_mcp_webhook_proxy"
55
url: "https://github.com/homeassistant-ai/ha-mcp"
66
arch:

homeassistant-addon-webhook-proxy/mcp_proxy/__init__.py

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import json
2121
import logging
2222
import re
23+
import threading
2324
from pathlib import Path
2425
from urllib.parse import urlparse
2526

@@ -46,6 +47,19 @@
4647
DOMAIN = "mcp_proxy"
4748
CONFIG_FILE = Path("/config/.mcp_proxy_config.json")
4849

50+
# Inbound-request mirror file. When "Log inbound requests" is on we append each
51+
# inbound debug line here in addition to logging it to Home Assistant, so the
52+
# Webhook Proxy addon (a separate process) can tail it and surface the same
53+
# lines in the addon log. Path kept in sync with start.py:INBOUND_LOG_FILE.
54+
INBOUND_LOG_FILE = Path("/config/.mcp_proxy_inbound.log")
55+
# Cap the mirror file so it can't grow without bound; trim to the last half
56+
# when exceeded. 256 KiB keeps plenty of recent history at ~100 bytes/line.
57+
_INBOUND_LOG_CAP = 256 * 1024
58+
# Serializes writes to INBOUND_LOG_FILE: HA dispatches _append_inbound_log to a
59+
# multi-worker executor pool, so concurrent inbound requests could interleave
60+
# the append + the cap's read-modify-write trim without this lock.
61+
_LOG_WRITE_LOCK = threading.Lock()
62+
4963
# ha-mcp generates a 22-char base64url token after `/private_`. We accept >=16
5064
# as a sanity floor — a truncated/corrupted ha-mcp config yields a shorter
5165
# token, which is the failure mode this length check exists to catch.
@@ -320,6 +334,45 @@ def _read_config() -> dict | None:
320334
return data
321335

322336

337+
def _append_inbound_log(line: str) -> None:
338+
"""Append one inbound-debug line to the mirror file the addon tails.
339+
340+
Capped: when the file grows past ``_INBOUND_LOG_CAP`` it is trimmed to its
341+
last half (dropping the now-partial first line) so it can't grow without
342+
bound. Best-effort — swallows its own ``OSError`` (e.g. a read-only
343+
``/config``) so a mirror failure never surfaces as an unretrieved executor
344+
exception. Blocking filesystem I/O — call via ``hass.async_add_executor_job``.
345+
"""
346+
try:
347+
with _LOG_WRITE_LOCK:
348+
with INBOUND_LOG_FILE.open("a", encoding="utf-8") as fh:
349+
fh.write(line + "\n")
350+
if INBOUND_LOG_FILE.stat().st_size > _INBOUND_LOG_CAP:
351+
data = INBOUND_LOG_FILE.read_bytes()[-(_INBOUND_LOG_CAP // 2) :]
352+
nl = data.find(b"\n")
353+
if nl != -1:
354+
data = data[nl + 1 :]
355+
INBOUND_LOG_FILE.write_bytes(data)
356+
except OSError as e:
357+
_LOGGER.debug("MCP Proxy: inbound mirror write failed: %s", e)
358+
359+
360+
async def _debug_log(hass: HomeAssistant, message: str) -> None:
361+
"""Log an inbound-request debug line to Home Assistant AND mirror it to the
362+
addon log file (``INBOUND_LOG_FILE``) so it surfaces in the Webhook Proxy
363+
addon log too, not only in Settings -> System -> Logs.
364+
365+
The mirror write is dispatched to the executor fire-and-forget: it runs off
366+
the event loop and we deliberately don't await it, so an opt-in debug log
367+
never adds latency to (or fails) the proxied request. ``_append_inbound_log``
368+
swallows its own ``OSError`` (the only realistic failure here, since the
369+
message is controlled ASCII), so the unawaited future does not carry an
370+
exception in practice.
371+
"""
372+
_LOGGER.info("%s", message)
373+
hass.async_add_executor_job(_append_inbound_log, message)
374+
375+
323376
async def _handle_webhook(
324377
hass: HomeAssistant, webhook_id: str, request: web.Request
325378
) -> web.StreamResponse:
@@ -340,12 +393,10 @@ async def _handle_webhook(
340393
# the logged source.
341394
source = request.remote or "unknown"
342395
has_auth = "present" if request.headers.get("Authorization") else "absent"
343-
_LOGGER.info(
344-
"MCP Proxy [inbound]: %s %s from %s (Authorization header: %s)",
345-
request.method,
346-
masked_path,
347-
source,
348-
has_auth,
396+
await _debug_log(
397+
hass,
398+
f"MCP Proxy [inbound]: {request.method} {masked_path} from {source} "
399+
f"(Authorization header: {has_auth})",
349400
)
350401

351402
# OAuth gate. When OAuth isn't configured, `oauth_provider` is None and
@@ -354,9 +405,10 @@ async def _handle_webhook(
354405
oauth_provider = data.get("oauth")
355406
if oauth_provider is not None and not oauth_provider.validate_bearer(request):
356407
if debug:
357-
_LOGGER.info(
408+
await _debug_log(
409+
hass,
358410
"MCP Proxy [inbound]: -> 401 Unauthorized (no/invalid OAuth "
359-
"bearer; expected for the initial discovery probe)"
411+
"bearer; expected for the initial discovery probe)",
360412
)
361413
from .oauth import build_unauthorized_response
362414

@@ -392,10 +444,10 @@ async def _handle_webhook(
392444
content_type = upstream_resp.headers.get("Content-Type", "")
393445

394446
if debug:
395-
_LOGGER.info(
396-
"MCP Proxy [inbound]: -> upstream responded %s (%s)",
397-
upstream_resp.status,
398-
content_type or "no content-type",
447+
await _debug_log(
448+
hass,
449+
f"MCP Proxy [inbound]: -> upstream responded "
450+
f"{upstream_resp.status} ({content_type or 'no content-type'})",
399451
)
400452

401453
# Common headers for both streaming and non-streaming

homeassistant-addon-webhook-proxy/mcp_proxy/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"dependencies": ["webhook"],
88
"documentation": "https://github.com/homeassistant-ai/ha-mcp",
99
"iot_class": "local_push",
10-
"version": "1.2.0"
10+
"version": "1.2.1"
1111
}

homeassistant-addon-webhook-proxy/mcp_proxy/oauth.py

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,53 @@
7979
# misconfiguration if a future caller forgets the up-front check.
8080
MIN_CLIENT_ID_LEN = 16
8181

82+
# Appended ONLY to the stale-OAuth-registration errors (invalid_client /
83+
# invalid client_id) via the _text_error/_json_error restart_hint flag. The OAuth
84+
# provider's HTTP views are bound at register_views() time and HA can't
85+
# re-register / drop them mid-session, so a client-id regenerate / OAuth toggle /
86+
# reinstall only takes effect on a full HA restart — the case this targets
87+
# (issue #1694). Deliberately NOT added to client-side protocol errors
88+
# (invalid_grant / invalid_request / unsupported_grant_type) or the webhook
89+
# 502/500 paths, where a restart is not the fix. (The webhook itself is
90+
# re-registered on reload; only the OAuth views need the restart.)
91+
RESTART_HINT = (
92+
"If this persists, fully restart Home Assistant "
93+
"(Settings -> System -> Restart) — not just the add-on or the integration."
94+
)
95+
96+
97+
def _text_error(
98+
status: int, message: str, *, restart_hint: bool = False
99+
) -> web.Response:
100+
"""Plain-text error response.
101+
102+
``restart_hint`` appends ``RESTART_HINT`` — set it only for the
103+
stale-OAuth-registration cases (``invalid client_id``) a full HA restart
104+
actually unsticks, not for client-side request mistakes.
105+
"""
106+
text = f"{message}. {RESTART_HINT}" if restart_hint else message
107+
return web.Response(status=status, text=text)
108+
109+
110+
def _json_error(
111+
error: str,
112+
status: int,
113+
headers: dict[str, str] | None = None,
114+
*,
115+
restart_hint: bool = False,
116+
) -> web.Response:
117+
"""OAuth JSON error response.
118+
119+
``restart_hint`` carries ``RESTART_HINT`` in ``error_description`` — set it
120+
only for the stale-registration case (``invalid_client``), not for
121+
client-side protocol errors (``invalid_grant`` / ``invalid_request`` /
122+
``unsupported_grant_type``) where a restart is not the fix.
123+
"""
124+
body = {"error": error}
125+
if restart_hint:
126+
body["error_description"] = RESTART_HINT
127+
return web.json_response(body, status=status, headers=headers)
128+
82129

83130
class _PendingCode(TypedDict):
84131
"""Shape of an entry in OAuthProvider._codes. TypedDict so a typo on
@@ -546,7 +593,7 @@ async def post(self, request: web.Request) -> web.Response:
546593
if action == "deny":
547594
return self._redirect_with(redirect_uri, error="access_denied", state=state)
548595
if action != "approve":
549-
return web.Response(status=400, text="invalid action")
596+
return _text_error(400, "invalid action")
550597

551598
code = self._provider.issue_code(redirect_uri, code_challenge)
552599
if code is None:
@@ -571,21 +618,17 @@ def _validate_authorize_params(
571618
identical validation — the POST path explicitly re-validates the
572619
hidden form fields rather than trusting them."""
573620
if response_type != "code":
574-
return web.Response(status=400, text="unsupported_response_type")
621+
return _text_error(400, "unsupported_response_type")
575622
if code_challenge_method != "S256":
576-
return web.Response(
577-
status=400, text="invalid code_challenge_method (S256 required)"
578-
)
623+
return _text_error(400, "invalid code_challenge_method (S256 required)")
579624
if not _PKCE_CHALLENGE_RE.match(code_challenge):
580-
return web.Response(
581-
status=400, text="invalid code_challenge (must be 43-char base64url)"
625+
return _text_error(
626+
400, "invalid code_challenge (must be 43-char base64url)"
582627
)
583628
if client_id != self._provider.client_id:
584-
return web.Response(status=400, text="invalid client_id")
629+
return _text_error(400, "invalid client_id", restart_hint=True)
585630
if not _is_valid_redirect_uri(redirect_uri):
586-
return web.Response(
587-
status=400, text="redirect_uri must be an https:// URL with a host"
588-
)
631+
return _text_error(400, "redirect_uri must be an https:// URL with a host")
589632
return None
590633

591634

@@ -623,27 +666,28 @@ async def post(self, request: web.Request) -> web.Response:
623666
form = dict(await request.post())
624667
client_id, client_secret = self._extract_client_creds(request, form)
625668
if not self._provider.authenticate_client(client_id, client_secret):
626-
return web.json_response(
627-
{"error": "invalid_client"},
628-
status=401,
669+
return _json_error(
670+
"invalid_client",
671+
401,
629672
headers={"WWW-Authenticate": 'Basic realm="MCP Proxy OAuth"'},
673+
restart_hint=True,
630674
)
631675

632676
grant_type = form.get("grant_type", "")
633677
if grant_type == "authorization_code":
634678
return await self._handle_authorization_code(form)
635679
if grant_type == "refresh_token":
636680
return await self._handle_refresh(form)
637-
return web.json_response({"error": "unsupported_grant_type"}, status=400)
681+
return _json_error("unsupported_grant_type", 400)
638682

639683
async def _handle_authorization_code(self, form: dict) -> web.Response:
640684
code = str(form.get("code", ""))
641685
redirect_uri = str(form.get("redirect_uri", ""))
642686
code_verifier = str(form.get("code_verifier", ""))
643687
if not (code and redirect_uri and code_verifier):
644-
return web.json_response({"error": "invalid_request"}, status=400)
688+
return _json_error("invalid_request", 400)
645689
if not self._provider.consume_code(code, redirect_uri, code_verifier):
646-
return web.json_response({"error": "invalid_grant"}, status=400)
690+
return _json_error("invalid_grant", 400)
647691
return web.json_response(
648692
{
649693
"access_token": self._provider.issue_access_token(),
@@ -656,7 +700,7 @@ async def _handle_authorization_code(self, form: dict) -> web.Response:
656700
async def _handle_refresh(self, form: dict) -> web.Response:
657701
refresh = str(form.get("refresh_token", ""))
658702
if not refresh or not self._provider.validate_refresh_token(refresh):
659-
return web.json_response({"error": "invalid_grant"}, status=400)
703+
return _json_error("invalid_grant", 400)
660704
return web.json_response(
661705
{
662706
"access_token": self._provider.issue_access_token(),

0 commit comments

Comments
 (0)