Skip to content

Commit cf2f365

Browse files
feat(webhook-proxy): inbound-request debug logging + connector docs (#1633)
* feat(webhook-proxy): inbound-request debug logging + connector docs - Add `debug_logging` toggle (visible on the main Configuration page); logs each inbound request to the HA log before the OAuth gate so the 401 discovery probe is captured too. Confirms whether a client is reaching the server. The integration logger is raised to INFO while on. - Document the Claude.ai web flow (add connector -> Connect -> Allow) and a public-reachability check across the addon DOCS, setup site, and FAQ. - Bump the addon to 1.2.0 and add a backfilled CHANGELOG.md. Closes #1627 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(webhook-proxy): debug_logging tests; sync manifest version; keep OFF-path hass.data shape - Bump integration manifest.json to 1.2.0 to match config.yaml (the addon/ integration version-match guard used by start.py update detection). - Add the debug_logging key to hass.data only when enabled, keeping the OFF-path shape identical to baseline (TestOAuthOffPreservesBehavior). - Add config-structure + TestDebugLogging tests (off/on hass.data shape, inbound-request logging); apply ruff format. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(webhook-proxy): address review (request.remote, symmetric logger guard, +tests) - Log the inbound source from request.remote (the HA-validated client IP) rather than the spoofable X-Forwarded-For header. - Raise the integration logger to INFO only when the effective level is less verbose, and on the OFF path only undo an INFO we ourselves raised — never clobber a user's explicit `logger:` level in either direction. - Add tests: 401-before-gate inbound logging, upstream-response log line, and all three logger-level branches (with an autouse logger-level reset). - Revert an unrelated cosmetic ruff reformat of oauth.py swept in by a broad `ruff format`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(webhook-proxy): type-check the mcp_proxy integration under mypy CI's mypy step only covered start.py, leaving the mcp_proxy integration package untyped on every platform (not a Windows quirk — it was never in the mypy command). Bring it under the same gate as custom_components/ and fix the gaps that surfaced: - pr.yml: mypy the whole homeassistant-addon-webhook-proxy/ dir, not just start.py. - config_flow.py: annotate the flow methods and `# type: ignore[call-arg]` the `domain=` subclass kwarg, mirroring custom_components/ha_mcp_tools. - oauth.py / __init__.py: fix two no-any-return warnings. - pyproject.toml: add yarl to the ignore_missing_imports override (it ships with aiohttp, already ignored). - Restore the repo-wide ruff format on oauth.py. 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 d8f3c97 commit cf2f365

16 files changed

Lines changed: 589 additions & 52 deletions

File tree

.github/workflows/pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ jobs:
249249
- name: Run mypy
250250
run: |
251251
uv run mypy src/ custom_components/ homeassistant-addon/ scripts/
252-
uv run mypy homeassistant-addon-webhook-proxy/start.py
252+
uv run mypy homeassistant-addon-webhook-proxy/
253253
254254
# Fast unit tests (no Docker, no HA instance needed)
255255
unit-tests:

docs/FAQ.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ You can also start a new conversation and ask Claude if it can see your Home Ass
6161

6262
This is a known Claude.ai behavior that affects all MCP servers, not just ha-mcp.
6363

64+
**If it genuinely won't connect** (not just the transient handshake error above): Claude.ai connects from Anthropic's servers, so the MCP URL must be reachable from the public internet — not just your LAN. A URL that works in Claude Code or a local browser can still be unreachable for Claude.ai web. Open the URL on your **phone with Wi-Fi off** (cellular): if it doesn't load there, it isn't publicly reachable (DNS / port-forward / TLS / reverse-proxy) and Claude.ai can't reach it either. Also make sure you clicked **Connect** on the connector (and, with OAuth enabled, **Allow** on the consent page) — adding the connector alone does not complete the connection.
65+
6466
### "Terminating session: None" in server logs
6567

6668
**This is normal.** ha-mcp runs in stateless HTTP mode, which means each request creates and discards a temporary session. The `Terminating session: None` log message is the MCP SDK reporting this routine cleanup — the connection stays active.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# CHANGELOG
2+
3+
<!-- version list -->
4+
5+
6+
## v1.2.0 (2026-06-15)
7+
8+
### Added
9+
10+
- Add a "Log inbound requests" debug toggle. When enabled, every request that
11+
reaches the webhook proxy is logged to the Home Assistant log (method, masked
12+
path, source address, whether an `Authorization` header was present, and the
13+
upstream response status) — making it easy to confirm whether an MCP client
14+
such as Claude.ai is actually reaching the server.
15+
16+
### Documentation
17+
18+
- Document the Claude.ai web custom-connector flow end to end (add the
19+
connector, click **Connect**, then **Allow** on the authorization page) and
20+
add a quick public-reachability check for diagnosing "Couldn't reach MCP
21+
server".
22+
23+
## v1.1.0 (2026-05-09)
24+
25+
### Added
26+
27+
- Optional OAuth 2.1 authentication mode for the webhook proxy (beta)
28+
([#1184](https://github.com/homeassistant-ai/ha-mcp/pull/1184))
29+
30+
## v1.0.2 (2026-05-03)
31+
32+
### Fixed
33+
34+
- Surface webhook registration failures instead of silently loading
35+
([#1101](https://github.com/homeassistant-ai/ha-mcp/pull/1101))
36+
37+
## v1.0.1 (2026-03-07)
38+
39+
### Fixed
40+
41+
- Correct webhook proxy Dockerfile COPY paths for Supervisor builds
42+
([#725](https://github.com/homeassistant-ai/ha-mcp/pull/725))
43+
44+
## v1.0.0 (2026-03-06)
45+
46+
### Added
47+
48+
- Nabu Casa and other generic remote access via the webhook proxy
49+
([#554](https://github.com/homeassistant-ai/ha-mcp/pull/554))

homeassistant-addon-webhook-proxy/DOCS.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ This addon enables remote access to your HA MCP Server through any reverse proxy
2323
```
2424
5. **Paste the URL** into your MCP client (Claude Desktop, Claude.ai, Open WebUI, etc.)
2525

26+
### Connecting from Claude.ai (web)
27+
28+
1. In Claude.ai, go to **Settings → Connectors → Add custom connector**, give it a name, paste the remote URL, and click **Add**.
29+
2. **Click _Connect_ on the new connector.** This step is required — adding the connector does not connect it. With OAuth enabled this opens the consent page; click **Allow** to finish. (With OAuth off there is no consent page and it connects directly.)
30+
3. Claude.ai may briefly show *"Couldn't reach the MCP server"* — this is often a harmless artifact of the initial handshake. Check whether the connector actually shows as connected before assuming it failed.
31+
32+
> **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.
33+
2634
> **Note:** If something doesn't seem to work after restarting HA, try restarting the addon as well.
2735
2836
## Configuration
@@ -36,8 +44,9 @@ This addon enables remote access to your HA MCP Server through any reverse proxy
3644
| `oauth_client_id` | OAuth Client ID (auto-generated if blank) | `""` |
3745
| `oauth_client_secret` | OAuth Client Secret (auto-generated if blank) | `""` |
3846
| `regenerate_oauth_creds` | One-shot: wipe stored OAuth creds and generate fresh ones on next start | `false` |
47+
| `debug_logging` | **Beta.** Log every inbound request to the Home Assistant log to confirm a client is reaching the server | `false` |
3948

40-
> The OAuth options are hidden by default. Toggle **Show unused optional configuration options** at the bottom of the addon's Configuration tab to reveal them.
49+
> The OAuth options are hidden by default. Toggle **Show unused optional configuration options** at the bottom of the addon's Configuration tab to reveal them. `debug_logging` is shown on the main Configuration page (it is not one of the hidden options).
4150
4251
### Auto-detection
4352

@@ -132,7 +141,7 @@ The generated values are persisted at `/data/oauth_creds.json` inside the addon,
132141

133142
### End-to-end flow (what happens when Claude.ai connects)
134143

135-
When OAuth is enabled and you paste the webhook URL + Client ID + Client Secret into Claude.ai's connector setup, here's what happens:
144+
When OAuth is enabled and you paste the webhook URL + Client ID + Client Secret into Claude.ai's connector setup, here's what happens after you click **Connect** on the connector:
136145

137146
1. **Claude.ai's browser session** is redirected to `https://<your-host>/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&state=...&resource=https://<your-host>/api/webhook/<id>`.
138147
2. The addon serves a **consent page** (Allow / Deny) showing the redirect destination so you can verify it's Claude.ai's callback URL before proceeding.
@@ -168,6 +177,24 @@ What stops an attacker who can reach the consent page from gaining access:
168177
- If you suspect it has leaked, **rotate it immediately** using one of the three rotation methods above. After rotation, the old Client Secret stops working — any tokens previously issued will fail at refresh, forcing the client to re-do the OAuth flow with the new credentials.
169178
- If you can't tell whether it leaked but want a clean slate (e.g., after sharing logs for debugging, after a migration), rotating proactively is cheap: flip **Regenerate OAuth Credentials on Next Start**, restart, paste the new credentials into Claude.ai. Takes ~30 seconds.
170179

180+
### Debugging connections (log inbound requests)
181+
182+
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**.
183+
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:
185+
186+
```
187+
MCP Proxy [inbound]: POST /api/webhook/mcp_3e... from 203.0.113.4 (Authorization header: present)
188+
MCP Proxy [inbound]: -> upstream responded 200 (text/event-stream)
189+
```
190+
191+
How to read it:
192+
193+
- **You see inbound lines** → the client is reaching the server; the problem is downstream (auth, the client's config, or the MCP server itself).
194+
- **You see nothing** → the request never arrived. The problem is network reachability — the public URL, DNS, TLS, or your reverse proxy — not this addon. See the reachability check under [Setup](#connecting-from-claudeai-web).
195+
196+
Turn it back off for normal operation (restart the addon after changing it).
197+
171198
## How it works
172199

173200
1. The addon installs a lightweight `mcp_proxy` custom integration into Home Assistant
@@ -203,6 +230,15 @@ If the `mcp_proxy` integration doesn't appear in Settings > Devices & Services:
203230
1. Restart Home Assistant (Settings > System > Restart)
204231
2. The addon will start automatically and retry setup
205232

233+
### Claude.ai says "Couldn't reach the MCP server"
234+
235+
Two cases:
236+
237+
1. **It actually connected.** Claude.ai sometimes shows this during the initial handshake even though the connector ends up working. Check whether the connector shows as connected before assuming failure.
238+
2. **It genuinely can't reach the URL.** Claude.ai connects from Anthropic's servers, so the URL must be reachable from the public internet — not just your LAN. Open the remote URL on your **phone with Wi-Fi off** (cellular): if it doesn't load, the URL isn't publicly reachable (DNS / port-forward / TLS / reverse-proxy) and Claude.ai can't reach it either. To confirm whether requests are arriving at all, enable **Log inbound requests** (see [Debugging connections](#debugging-connections-log-inbound-requests)).
239+
240+
> **Note:** A connection working in Claude Code or a local browser but **not** in Claude.ai web is the classic signature of this — those reach your box over the LAN, while Claude.ai reaches it from the public internet.
241+
206242
## Disabling / Uninstalling
207243

208244
- **Stopping** the addon is safe — the webhook URL stays the same and resumes working when the addon is restarted

homeassistant-addon-webhook-proxy/config.yaml

Lines changed: 3 additions & 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.1.0"
3+
version: "1.2.0"
44
slug: "ha_mcp_webhook_proxy"
55
url: "https://github.com/homeassistant-ai/ha-mcp"
66
arch:
@@ -19,6 +19,7 @@ options:
1919
remote_url: ""
2020
mcp_server_url: ""
2121
mcp_port: 9583
22+
debug_logging: false
2223
schema:
2324
remote_url: str?
2425
mcp_server_url: str?
@@ -27,3 +28,4 @@ schema:
2728
oauth_client_id: str?
2829
oauth_client_secret: password?
2930
regenerate_oauth_creds: bool?
31+
debug_logging: bool?

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

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636

3737
_LOGGER = logging.getLogger(__name__)
3838

39+
# Tracks whether *this process* raised the logger to INFO for the debug toggle,
40+
# so the off path undoes only our own raise — never a level the user set via
41+
# Home Assistant's `logger:` config. Module-global (not hass.data) because it
42+
# must survive a config-entry reload, during which hass.data[DOMAIN] is gone.
43+
_LOGGER_LEVEL_RAISED = False
44+
3945
DOMAIN = "mcp_proxy"
4046
CONFIG_FILE = Path("/config/.mcp_proxy_config.json")
4147

@@ -87,12 +93,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
8793
"You can safely remove 'mcp_proxy:' from configuration.yaml."
8894
)
8995
hass.async_create_task(
90-
hass.config_entries.flow.async_init(
91-
DOMAIN, context={"source": "import"}
92-
)
96+
hass.config_entries.flow.async_init(DOMAIN, context={"source": "import"})
9397
)
9498
if await hass.async_add_executor_job(_marker_present):
9599
from .repairs import maybe_create_issue
100+
96101
maybe_create_issue(hass, DOMAIN)
97102
return True
98103

@@ -101,6 +106,7 @@ def _marker_present() -> bool:
101106
# Imported lazily so async_setup doesn't pull in repairs.py module-load
102107
# cost on the no-marker happy path.
103108
from .repairs import marker_present
109+
104110
return marker_present()
105111

106112

@@ -158,6 +164,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
158164
_LOGGER.info("MCP Proxy: target = %s", masked_target)
159165
_LOGGER.info("MCP Proxy: webhook endpoint = /api/webhook/%s", masked_wh)
160166

167+
# Inbound-request debug logging (addon "Log inbound requests" toggle).
168+
# Custom-component loggers default to WARNING, so when the toggle is on we
169+
# raise our own logger to INFO so the per-request lines are emitted — but
170+
# only when the effective level is less verbose, so we never override an
171+
# explicit DEBUG/INFO the user set via Home Assistant's `logger:` config. We
172+
# track whether WE raised it and, when the toggle is off, undo only our own
173+
# raise — never a level the user set themselves.
174+
global _LOGGER_LEVEL_RAISED
175+
debug_logging = bool(proxy_config.get("debug_logging", False))
176+
if debug_logging and _LOGGER.getEffectiveLevel() > logging.INFO:
177+
_LOGGER.setLevel(logging.INFO)
178+
_LOGGER_LEVEL_RAISED = True
179+
elif not debug_logging and _LOGGER_LEVEL_RAISED:
180+
# Undo only the INFO we raised. (If a user had set an explicit level
181+
# quieter than INFO — ERROR/CRITICAL — then toggled debug on then off,
182+
# this resets to NOTSET rather than their original level; restoring that
183+
# would need durable per-level state, not worth it for a debug aid.)
184+
_LOGGER.setLevel(logging.NOTSET)
185+
_LOGGER_LEVEL_RAISED = False
186+
if debug_logging:
187+
_LOGGER.info(
188+
"MCP Proxy: inbound request debug logging is ON — each request to "
189+
"this webhook will be logged here."
190+
)
191+
161192
session = aiohttp.ClientSession(
162193
timeout=aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300),
163194
)
@@ -177,15 +208,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
177208
masked_wh,
178209
)
179210
await session.close()
180-
raise ConfigEntryError(
181-
f"Failed to register webhook endpoint: {err}"
182-
) from err
211+
raise ConfigEntryError(f"Failed to register webhook endpoint: {err}") from err
183212

184213
hass_data: dict = {
185214
"target_url": target_url,
186215
"webhook_id": webhook_id,
187216
"session": session,
188217
}
218+
# Mirror the oauth pattern: only add the key when the feature is on, so the
219+
# default/OFF path's hass.data shape stays identical to the baseline
220+
# (target_url, webhook_id, session) — guarded by TestOAuthOffPreservesBehavior.
221+
if debug_logging:
222+
hass_data["debug_logging"] = True
189223

190224
# OAuth is opt-in. When the addon writes an `oauth` section into the
191225
# config file (only when enable_oauth is on AND both creds are non-empty,
@@ -215,11 +249,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
215249
if not isinstance(public_base_url, str) or not public_base_url:
216250
public_base_url = None
217251
from .oauth import OAuthProvider, load_or_create_secret
252+
218253
try:
219254
# Filesystem I/O — must run off the event loop.
220-
signing_key = await hass.async_add_executor_job(
221-
load_or_create_secret
222-
)
255+
signing_key = await hass.async_add_executor_job(load_or_create_secret)
223256
oauth_provider = OAuthProvider(
224257
hass=hass,
225258
client_id=client_id,
@@ -256,6 +289,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
256289
# in the executor; the issue-registry call is synchronous and safe on
257290
# the event loop.
258291
from .repairs import _clear_marker, _delete_issue_only
292+
259293
await hass.async_add_executor_job(_clear_marker)
260294
_delete_issue_only(hass, DOMAIN)
261295

@@ -271,7 +305,8 @@ def _read_config() -> dict | None:
271305
"""
272306
if not CONFIG_FILE.exists():
273307
return None
274-
return json.loads(CONFIG_FILE.read_text())
308+
data: dict | None = json.loads(CONFIG_FILE.read_text())
309+
return data
275310

276311

277312
async def _handle_webhook(
@@ -281,12 +316,39 @@ async def _handle_webhook(
281316
data = hass.data[DOMAIN]
282317
target_url = data["target_url"]
283318

319+
# Inbound-request debug logging (opt-in). Logged BEFORE the OAuth gate so
320+
# the unauthenticated discovery probe (which gets a 401) is captured too —
321+
# that probe arriving is the proof a client actually reached the server.
322+
debug = data.get("debug_logging")
323+
if debug:
324+
wh = data["webhook_id"]
325+
masked_path = f"/api/webhook/{wh[:6]}..." if len(wh) > 6 else "/api/webhook/***"
326+
# request.remote is the client IP validated by HA's trusted-proxy layer
327+
# (it resolves X-Forwarded-For when the proxy is trusted). Reading the
328+
# raw X-Forwarded-For header here would let an untrusted client spoof
329+
# the logged source.
330+
source = request.remote or "unknown"
331+
has_auth = "present" if request.headers.get("Authorization") else "absent"
332+
_LOGGER.info(
333+
"MCP Proxy [inbound]: %s %s from %s (Authorization header: %s)",
334+
request.method,
335+
masked_path,
336+
source,
337+
has_auth,
338+
)
339+
284340
# OAuth gate. When OAuth isn't configured, `oauth_provider` is None and
285341
# this branch is a single attribute lookup with zero behavior change vs
286342
# the original handler.
287343
oauth_provider = data.get("oauth")
288344
if oauth_provider is not None and not oauth_provider.validate_bearer(request):
345+
if debug:
346+
_LOGGER.info(
347+
"MCP Proxy [inbound]: -> 401 Unauthorized (no/invalid OAuth "
348+
"bearer; expected for the initial discovery probe)"
349+
)
289350
from .oauth import build_unauthorized_response
351+
290352
return build_unauthorized_response(request, oauth_provider)
291353

292354
body = await request.read()
@@ -295,8 +357,12 @@ async def _handle_webhook(
295357
forward_headers = {}
296358
for key, value in request.headers.items():
297359
if key.lower() in (
298-
"host", "content-length", "transfer-encoding", "connection",
299-
"cookie", "authorization",
360+
"host",
361+
"content-length",
362+
"transfer-encoding",
363+
"connection",
364+
"cookie",
365+
"authorization",
300366
):
301367
continue
302368
forward_headers[key] = value
@@ -314,6 +380,13 @@ async def _handle_webhook(
314380
) as upstream_resp:
315381
content_type = upstream_resp.headers.get("Content-Type", "")
316382

383+
if debug:
384+
_LOGGER.info(
385+
"MCP Proxy [inbound]: -> upstream responded %s (%s)",
386+
upstream_resp.status,
387+
content_type or "no content-type",
388+
)
389+
317390
# Common headers for both streaming and non-streaming
318391
resp_headers = {
319392
"Cache-Control": "no-cache, no-transform",

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
"""Config flow for MCP Webhook Proxy."""
22

3-
from homeassistant.config_entries import ConfigFlow
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
48

59
DOMAIN = "mcp_proxy"
610

711

8-
class McpProxyConfigFlow(ConfigFlow, domain=DOMAIN):
12+
class McpProxyConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
913
"""Handle a config flow for MCP Webhook Proxy."""
1014

1115
VERSION = 1
1216

13-
async def async_step_user(self, user_input=None):
17+
async def async_step_user(
18+
self, user_input: dict[str, Any] | None = None
19+
) -> ConfigFlowResult:
1420
"""Handle setup via the UI."""
1521
await self.async_set_unique_id(DOMAIN)
1622
self._abort_if_unique_id_configured()
@@ -20,7 +26,9 @@ async def async_step_user(self, user_input=None):
2026

2127
return self.async_show_form(step_id="user")
2228

23-
async def async_step_import(self, import_data=None):
29+
async def async_step_import(
30+
self, import_data: dict[str, Any] | None = None
31+
) -> ConfigFlowResult:
2432
"""Handle auto-import from YAML migration or addon API call."""
2533
await self.async_set_unique_id(DOMAIN)
2634
self._abort_if_unique_id_configured()

0 commit comments

Comments
 (0)