Skip to content

Commit d45fcd7

Browse files
feat!: remove ha-mcp-sse entry point (#2032)
* feat!: remove ha-mcp-sse entry point The MCP specification has formally deprecated the HTTP+SSE transport. No install method or documentation advertised ha-mcp-sse; Streamable HTTP (ha-mcp-web) is the supported HTTP mode. Removes main_sse, the ha-mcp-sse console script, fastmcp-sse.json, the SSE branches in _http_run_kwargs / ProbeAccessLogFilter / register_browser_landing, the SSE entry-point detection in the bug report tool, and the SSE notes on the setup page. BREAKING CHANGE: the ha-mcp-sse console script and SSE transport mode are removed; use ha-mcp-web (Streamable HTTP) instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * test: cover FASTMCP_TRANSPORT=sse env hint; sweep stale HTTP/SSE comments Review findings: the FASTMCP_TRANSPORT=sse branch in _detect_mcp_transport is now the sole path to "sse" detection and had no direct test, and seven comments/docstrings outside __main__.py still described an HTTP/SSE deployment mode that no longer exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2a28e42 commit d45fcd7

16 files changed

Lines changed: 91 additions & 227 deletions

SECURITY.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ Consequences:
3434

3535
### Local network is the trusted zone for standard mode
3636

37-
The HTTP entrypoints (`ha-mcp-web`, `ha-mcp-sse`) authenticate by URL-path
38-
secrecy and are designed for loopback HTTP or LAN HTTP with a high-entropy
37+
The HTTP entrypoint (`ha-mcp-web`) authenticates by URL-path
38+
secrecy and is designed for loopback HTTP or LAN HTTP with a high-entropy
3939
`MCP_SECRET_PATH`. Any peer that can reach the configured path is treated as
4040
trusted — securing the local network is outside ha-mcp's scope.
4141

@@ -79,7 +79,7 @@ Operators who front ha-mcp differently can re-enable the guard by setting
7979

8080
### Standard mode is single-tenant
8181

82-
The secret-URL model (`ha-mcp-web`, `ha-mcp-sse`) assumes a single operator.
82+
The secret-URL model (`ha-mcp-web`) assumes a single operator.
8383
All MCP clients that share the same `MCP_SECRET_PATH` get identical access —
8484
there is no per-client authorization or isolation. Reports that assume "client A
8585
shouldn't be able to see client B's data" don't apply to standard mode; that

fastmcp-sse.json

Lines changed: 0 additions & 17 deletions
This file was deleted.

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ ha-mcp = "ha_mcp.__main__:main"
5656
ha-mcp-smoke-test = "ha_mcp.smoke_test:main"
5757
hamcp-test-env = "tests.test_env_manager:main"
5858
ha-mcp-web = "ha_mcp.__main__:main_web"
59-
ha-mcp-sse = "ha_mcp.__main__:main_sse"
6059
ha-mcp-oauth = "ha_mcp.__main__:main_oauth"
6160
ha-mcp-oidc = "ha_mcp.__main__:main_oidc"
6261

site/src/pages/setup.astro

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1769,13 +1769,13 @@ ingress:
17691769
}
17701770

17711771
// Transport disclaimer (all HTTP-based setups): Streamable HTTP is the modern standard
1772-
// supported by virtually all current clients; SSE is legacy and only applies to the
1773-
// dedicated ha-mcp-sse entry point — not the app / ha-mcp-web.
1772+
// supported by virtually all current clients; the MCP spec has deprecated HTTP+SSE and
1773+
// nothing here serves it, so an SSE-style client config gets a 405.
17741774
if (!isLocal) {
17751775
instructions.push(`<div class="instruction-block border-blue-700/50 bg-blue-900/10">
17761776
<h3 class="instruction-title text-blue-300">Transport: use Streamable HTTP, not SSE</h3>
17771777
<p class="text-slate-300 text-sm">The ha-mcp app (and <code class="bg-slate-800 px-1 rounded">ha-mcp-web</code>) serve <strong>Streamable HTTP</strong> — the modern default, supported by virtually all current MCP clients (Claude Code, Claude.ai, ChatGPT, Cursor, GitHub Copilot, and more). The config above already uses it.</p>
1778-
<p class="text-slate-300 text-sm mt-2"><strong>SSE is legacy.</strong> You only need it for the separate <code class="bg-slate-800 px-1 rounded">ha-mcp-sse</code> entry point (deprecated SSE on port 8087). The app does <strong>not</strong> serve SSE, so an SSE / <code class="bg-slate-800 px-1 rounded">--transport sse</code> config pointed at it returns a <code class="bg-slate-800 px-1 rounded">405</code> and won't connect.</p>
1778+
<p class="text-slate-300 text-sm mt-2"><strong>SSE is not offered.</strong> The MCP spec has deprecated the HTTP+SSE transport, so an SSE / <code class="bg-slate-800 px-1 rounded">--transport sse</code> config pointed at the app returns a <code class="bg-slate-800 px-1 rounded">405</code> and won't connect — switch any older SSE-style client config over to Streamable HTTP.</p>
17791779
</div>`);
17801780
}
17811781

@@ -1800,7 +1800,7 @@ ingress:
18001800
instructions.push(`<div class="instruction-block border-blue-700/50 bg-blue-900/10">
18011801
<h3 class="instruction-title text-blue-300">Gemini CLI Notes</h3>
18021802
<p class="text-slate-300 text-sm mb-2"><strong>If client logs show <code>405 Method Not Allowed</code> on <code>GET /mcp</code>:</strong> the server is running in Streamable HTTP mode — switch from <code>url</code> to <code>httpUrl</code> in <code>~/.gemini/settings.json</code>. The config below already uses <code>httpUrl</code>.</p>
1803-
<p class="text-slate-300 text-sm mb-2 mt-3">Entry points: <code>ha-mcp-web</code> (default) serves Streamable HTTP at <code>/mcp</code> on port 8086. The separate <code>ha-mcp-sse</code> entry point serves legacy SSE on port 8087 — pair that with the <code>url</code> key (not <code>httpUrl</code>) if you need SSE specifically.</p>
1803+
<p class="text-slate-300 text-sm mb-2 mt-3">Entry point: <code>ha-mcp-web</code> (default) serves Streamable HTTP at <code>/mcp</code> on port 8086 — always pair it with the <code>httpUrl</code> key, not <code>url</code>.</p>
18041804
<p class="text-slate-300 text-sm mb-2 mt-3"><strong>A direct browser/curl probe of <code>http://&lt;host&gt;:8086/mcp</code> also returns 405 by design</strong> — that is the <code>register_browser_landing</code> handler in <code>src/ha_mcp/__main__.py</code> confirming the server is up, not a fault. MCP clients use <code>POST</code>; this only shows up if a curl probe or browser hits the URL directly.</p>
18051805
<p class="text-slate-300 text-sm mt-3"><strong>Alternative: HA built-in MCP integration.</strong> If you have the <a href="https://www.home-assistant.io/integrations/mcp_server/" target="_blank" rel="noopener noreferrer" class="text-blue-400 hover:underline">Home Assistant MCP integration</a> enabled, you can skip <code>ha-mcp</code> entirely and point Gemini CLI at HA's <code>/api/mcp</code> endpoint with a long-lived access token in a <code>Bearer</code> header. HA core also serves the legacy SSE endpoint at <code>/mcp_server/sse</code> on the same host/port — same 405-on-GET caveat applies there.</p>
18061806
</div>`);

src/ha_mcp/__main__.py

Lines changed: 34 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -306,28 +306,21 @@ def _setup_standard_mode() -> None:
306306
_log_startup_version()
307307

308308

309-
def _http_run_kwargs(transport: str, host: str, port: int, path: str) -> dict[str, Any]:
310-
"""Build common run_async kwargs for HTTP-based transports.
311-
312-
``stateless_http`` is a Streamable-HTTP concept and is only valid for the
313-
``http``/``streamable-http`` transports. Passing it alongside
314-
``transport="sse"`` makes fastmcp's ``run_async`` raise
315-
``ValueError("SSE transport does not support stateless mode")``. Gating it to
316-
non-SSE transports keeps SSE startup working. (Before this fix that raise was
317-
also swallowed into a silent exit 0; ``_run_with_shutdown`` now surfaces a
318-
self-terminating server task's exception instead.) See #1544.
309+
def _http_run_kwargs(host: str, port: int, path: str) -> dict[str, Any]:
310+
"""Build common run_async kwargs for the Streamable HTTP transport.
311+
312+
Every HTTP entry point (``ha-mcp-web``, OAuth, OIDC) runs Streamable HTTP,
313+
so ``stateless_http`` — a Streamable-HTTP-only concept — always applies.
319314
"""
320-
kwargs: dict[str, Any] = {
321-
"transport": transport,
315+
return {
316+
"transport": "http",
322317
"host": host,
323318
"port": port,
324319
"path": path,
320+
"stateless_http": True,
325321
"show_banner": _get_show_banner(),
326322
"uvicorn_config": {"log_config": _get_timestamped_uvicorn_log_config()},
327323
}
328-
if transport != "sse":
329-
kwargs["stateless_http"] = True
330-
return kwargs
331324

332325

333326
def _create_server() -> "HomeAssistantSmartMCPServer":
@@ -458,14 +451,12 @@ class ProbeAccessLogFilter(logging.Filter):
458451
health check, reverse proxy, or a connector's SSE-style pre-flight) hit a
459452
POST-only Streamable HTTP endpoint. The raw access line is dropped and the
460453
landing handler logs one annotated "(NORMAL for most non-SSE connections)"
461-
line in its place. Dropped only when ``drop_mcp_405`` is set — SSE callers
462-
pass False, since there a GET answers 200 and a GET-405 is a genuine fault.
454+
line in its place.
463455
"""
464456

465-
def __init__(self, mcp_path: str, *, drop_mcp_405: bool = True) -> None:
457+
def __init__(self, mcp_path: str) -> None:
466458
super().__init__()
467459
self._mcp_path = mcp_path.rstrip("/") or "/"
468-
self._drop_mcp_405 = drop_mcp_405
469460

470461
def filter(self, record: logging.LogRecord) -> bool:
471462
# uvicorn.access records carry structured args: (client, method, path,
@@ -483,12 +474,8 @@ def filter(self, record: logging.LogRecord) -> bool:
483474
return False # opt-in liveness probe (register_healthz) — pure noise
484475
# By-design probe 405 on the MCP path; the handler logs an annotated line
485476
# instead. This trusts that the landing route is the only GET/HEAD responder
486-
# on the MCP path (true today). Kept in SSE mode (drop_mcp_405=False), where
487-
# a GET answers 200 and a 405 is a real fault.
488-
is_dropped_probe = (
489-
status == 405 and path == self._mcp_path and self._drop_mcp_405
490-
)
491-
return not is_dropped_probe
477+
# on the MCP path (true today).
478+
return not (status == 405 and path == self._mcp_path)
492479

493480

494481
def _setup_logging(log_level_str: str, force: bool = False) -> None:
@@ -967,7 +954,7 @@ def main_dev() -> None:
967954

968955
# HTTP entry point for web clients
969956
def _get_http_runtime(default_port: int = 8086) -> tuple[str, int, str]:
970-
"""Return runtime configuration shared by HTTP transports.
957+
"""Return runtime configuration shared by the HTTP entry points.
971958
972959
Args:
973960
default_port: Default port to use if MCP_PORT env var is not set.
@@ -1048,12 +1035,12 @@ def _is_running_in_container() -> bool:
10481035
def _warn_if_default_path_exposed(host: str, port: int, path: str) -> None:
10491036
"""Warn on a direct run that leaves the default path on a LAN bind.
10501037
1051-
Standard-mode HTTP/SSE authenticates by URL-path secrecy (see
1038+
Standard-mode HTTP authenticates by URL-path secrecy (see
10521039
SECURITY.md → Threat Model). The default ``/mcp`` is not the
10531040
high-entropy secret that model assumes once the bind leaves loopback.
10541041
1055-
Fires only for a direct ``ha-mcp-web`` / ``ha-mcp-sse`` start (uvx, pip,
1056-
source) that uses the default path on a non-loopback host. Operators
1042+
Fires only for a direct ``ha-mcp-web`` start (uvx, pip, source) that uses
1043+
the default path on a non-loopback host. Operators
10571044
silence it the same way they harden — bind ``MCP_HOST=127.0.0.1`` or set
10581045
a high-entropy ``MCP_SECRET_PATH``. Containers are skipped: an
10591046
in-container ``0.0.0.0`` bind says nothing about real exposure, which is
@@ -1068,7 +1055,7 @@ def _warn_if_default_path_exposed(host: str, port: int, path: str) -> None:
10681055
return
10691056
logger.warning(
10701057
"ha-mcp listening on %s:%s%s with default MCP_SECRET_PATH. "
1071-
"Standard-mode HTTP/SSE authenticates by URL-path secrecy and assumes "
1058+
"Standard-mode HTTP authenticates by URL-path secrecy and assumes "
10721059
"a high-entropy MCP_SECRET_PATH for non-loopback binds "
10731060
"(see SECURITY.md → Threat Model). "
10741061
"Either bind loopback (MCP_HOST=127.0.0.1) or set MCP_SECRET_PATH "
@@ -1080,15 +1067,12 @@ def _warn_if_default_path_exposed(host: str, port: int, path: str) -> None:
10801067

10811068

10821069
async def _run_http_with_graceful_shutdown(
1083-
transport: str,
10841070
host: str,
10851071
port: int,
10861072
path: str,
10871073
) -> None:
10881074
"""Run HTTP server with graceful shutdown support."""
1089-
await _run_with_shutdown(
1090-
_get_mcp().run_async(**_http_run_kwargs(transport, host, port, path))
1091-
)
1075+
await _run_with_shutdown(_get_mcp().run_async(**_http_run_kwargs(host, port, path)))
10921076

10931077

10941078
def _healthz_enabled() -> bool:
@@ -1137,8 +1121,6 @@ def _oidc_allowed_client_redirect_uris() -> list[str] | None:
11371121
def register_browser_landing(
11381122
mcp_instance: "FastMCP | _DeferredMCP",
11391123
path: str,
1140-
*,
1141-
quiet_probe_log: bool = True,
11421124
) -> None:
11431125
"""Register the friendly browser landing page and tidy the uvicorn access log.
11441126
@@ -1152,24 +1134,16 @@ def register_browser_landing(
11521134
Args:
11531135
mcp_instance: The FastMCP server to register the route on.
11541136
path: The MCP endpoint path (e.g. "/mcp" or a secret path).
1155-
quiet_probe_log: When True (default, for Streamable HTTP), drop the
1156-
by-design GET/HEAD-405 probe line on the MCP path from the uvicorn
1157-
access log (the handler logs an annotated replacement). Pass False
1158-
for SSE, where a GET answers 200 and a 405 is a genuine fault.
11591137
"""
11601138
if not _register_landing_route(mcp_instance, path):
11611139
# Already registered for this path — don't double-attach the log filter.
11621140
return
11631141

1164-
# Tidy uvicorn's access log: always drop browser favicon 404s, and drop the
1165-
# raw by-design GET/HEAD-405 probe line on the MCP path (the landing handler
1166-
# logs an annotated replacement). The 405 drop is skipped for SSE
1167-
# (quiet_probe_log=False), where a GET answers 200 and a 405 is a real fault.
1168-
# Attach to uvicorn.access directly — it has propagate=False, so a root-logger
1169-
# filter would miss it.
1170-
logging.getLogger("uvicorn.access").addFilter(
1171-
ProbeAccessLogFilter(path, drop_mcp_405=quiet_probe_log)
1172-
)
1142+
# Tidy uvicorn's access log: drop browser favicon 404s and the raw by-design
1143+
# GET/HEAD-405 probe line on the MCP path (the landing handler logs an
1144+
# annotated replacement). Attach to uvicorn.access directly — it has
1145+
# propagate=False, so a root-logger filter would miss it.
1146+
logging.getLogger("uvicorn.access").addFilter(ProbeAccessLogFilter(path))
11731147

11741148

11751149
def _log_settings_url(
@@ -1229,7 +1203,7 @@ def _log_settings_url(
12291203

12301204
# Truthy / falsy env-var spellings for HA_MCP_DISABLE_SETTINGS_UI, matching the
12311205
# parsing in stdio_settings_sidecar.py so the toggle behaves the same across
1232-
# transports. A value in neither set is unrecognized: fail closed (disable) with
1206+
# entry points. A value in neither set is unrecognized: fail closed (disable) with
12331207
# a warning, since this is a security kill switch and failing open on a typo
12341208
# would leave an unauthenticated surface up.
12351209
_SETTINGS_TRUTHY = {"1", "true", "yes", "on"}
@@ -1239,9 +1213,9 @@ def _log_settings_url(
12391213
def _settings_ui_disabled() -> bool:
12401214
"""Return True when ``HA_MCP_DISABLE_SETTINGS_UI`` turns the settings UI off.
12411215
1242-
Honored by every long-lived HTTP transport (standard ``ha-mcp-web`` /
1243-
``ha-mcp-sse`` as well as OAuth/OIDC) and, with matching semantics, by the
1244-
stdio sidecar. As a security kill switch it fails **closed**: any value that
1216+
Honored by every long-lived HTTP transport (standard ``ha-mcp-web`` as well
1217+
as OAuth/OIDC) and, with matching semantics, by the stdio sidecar. As a
1218+
security kill switch it fails **closed**: any value that
12451219
is neither a recognized truthy nor falsy spelling disables the UI and warns
12461220
(so the operator learns why it vanished), rather than leaving an
12471221
unauthenticated surface up on a typo. Unset — or an explicit falsy value —
@@ -1373,11 +1347,10 @@ def _register_settings_ui_secret_path(
13731347
)
13741348

13751349

1376-
def _run_http_server(transport: str, default_port: int = 8086) -> None:
1377-
"""Common runner for HTTP-based transports.
1350+
def _run_http_server(default_port: int = 8086) -> None:
1351+
"""Common runner for the standard-mode Streamable HTTP server.
13781352
13791353
Args:
1380-
transport: Transport type (http or sse).
13811354
default_port: Default port to use if MCP_PORT env var is not set.
13821355
"""
13831356
from ha_mcp.settings_ui import register_settings_routes
@@ -1386,9 +1359,7 @@ def _run_http_server(transport: str, default_port: int = 8086) -> None:
13861359
# _get_mcp below, before the app is built) -- see transport_security.
13871360
host, port, path = _get_http_runtime(default_port)
13881361
_warn_if_default_path_exposed(host, port, path)
1389-
# SSE transport answers GET with 200 (the event stream), so a GET->405 there
1390-
# would be a real fault, not a benign probe — keep its access log intact.
1391-
register_browser_landing(_get_mcp(), path, quiet_probe_log=transport != "sse")
1362+
register_browser_landing(_get_mcp(), path)
13921363
if _healthz_enabled():
13931364
_register_healthz_route(_get_mcp())
13941365
if _settings_ui_disabled():
@@ -1400,7 +1371,7 @@ def _run_http_server(transport: str, default_port: int = 8086) -> None:
14001371
_log_settings_url(host, port, path)
14011372

14021373
_run_entrypoint(
1403-
_run_http_with_graceful_shutdown(transport, host, port, path),
1374+
_run_http_with_graceful_shutdown(host, port, path),
14041375
"HTTP server",
14051376
)
14061377

@@ -1418,23 +1389,7 @@ def main_web() -> None:
14181389
settings UI at all
14191390
"""
14201391
_setup_standard_mode()
1421-
_run_http_server("http", default_port=8086)
1422-
1423-
1424-
def main_sse() -> None:
1425-
"""Run server using Server-Sent Events transport for MCP clients.
1426-
1427-
Environment:
1428-
- HOMEASSISTANT_URL (required)
1429-
- HOMEASSISTANT_TOKEN (required)
1430-
- MCP_HOST (optional, default: "0.0.0.0"; set 127.0.0.1 to restrict to loopback)
1431-
- MCP_PORT (optional, default: 8087)
1432-
- MCP_SECRET_PATH (optional, default: "/mcp")
1433-
- HA_MCP_DISABLE_SETTINGS_UI (optional): set truthy to not serve the web
1434-
settings UI at all
1435-
"""
1436-
_setup_standard_mode()
1437-
_run_http_server("sse", default_port=8087)
1392+
_run_http_server(default_port=8086)
14381393

14391394

14401395
def main_oauth() -> None:
@@ -1568,9 +1523,7 @@ async def _run_oauth_server(
15681523
f"Starting OAuth-enabled MCP server with {len(tools)} tools on {base_url}{path}"
15691524
)
15701525

1571-
await _run_with_shutdown(
1572-
mcp.run_async(**_http_run_kwargs("http", host, port, path))
1573-
)
1526+
await _run_with_shutdown(mcp.run_async(**_http_run_kwargs(host, port, path)))
15741527

15751528

15761529
def main_oidc() -> None:
@@ -1763,7 +1716,7 @@ async def _run_oidc_server(
17631716
logger.info(f"Starting OIDC-enabled MCP server at {base_url}{path}")
17641717

17651718
await _run_with_shutdown(
1766-
mcp_instance.run_async(**_http_run_kwargs("http", host, port, path))
1719+
mcp_instance.run_async(**_http_run_kwargs(host, port, path))
17671720
)
17681721

17691722

src/ha_mcp/browser_landing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def register_healthz(mcp_instance: CustomRouteServer) -> bool:
150150
"""Register a GET ``/healthz`` liveness route answering 200 with JSON.
151151
152152
Lets uptime monitors, blackbox probes, and load balancers confirm the HTTP
153-
server is alive without knowing the MCP path. Standard-mode HTTP/SSE
153+
server is alive without knowing the MCP path. Standard-mode HTTP
154154
authenticates by URL-path secrecy (SECURITY.md -> Threat Model), so this
155155
route is opt-in (``MCP_HEALTHZ`` env var, checked by the caller) and the
156156
response body deliberately does not echo the MCP path.

src/ha_mcp/settings_ui/_handlers_advanced.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ async def _get_advanced_settings(
166166
)
167167
]
168168
# is_stdio: the sidecar-port field only applies when this settings page
169-
# is served by the stdio settings-UI sidecar. In HTTP/SSE/OAuth/addon
169+
# is served by the stdio settings-UI sidecar. In HTTP/OAuth/addon
170170
# deployments there is no sidecar, so the UI greys the section.
171171
return JSONResponse(
172172
{

src/ha_mcp/settings_ui/settings.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3619,7 +3619,7 @@ async function loadAdvancedSettings() {
36193619

36203620
function applySidecarAvailability(isStdio) {
36213621
// The sidecar-port setting only applies when this settings page is served
3622-
// by the stdio settings-UI sidecar. In HTTP/SSE/OAuth/addon deployments
3622+
// by the stdio settings-UI sidecar. In HTTP/OAuth/addon deployments
36233623
// there is no sidecar, so dim + disable the section and explain why, rather
36243624
// than letting a user save a value that does nothing.
36253625
// Remove any note from a prior load first, so the <h2> title is once again

0 commit comments

Comments
 (0)