{"repo":"NousResearch/hermes-agent","public_id":"36052","display_label":"MCP HTTP endpoint fast-fail on wrong content type","summary":"These issues and PRs address slow 60-second timeouts when connecting to non-MCP HTTP endpoints that return HTML or other unexpected content types. The work focuses on detecting invalid responses early and failing fast instead of waiting for the full timeout.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":37133,"merged_at":"2026-06-02T02:49:51+00:00"},"resolution":{"state":"resolved","by":"pr-37133"},"freshness":{"state":"verified","last_verified_at":"2026-06-02T02:49:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":36052,"pr_number":37133,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-02T02:49:51+00:00"}],"members":[{"kind":"issue","number":36052,"title":"Bad HTTP MCP endpoint returning HTML blocks discovery for 60s before CancelledError","url":null,"state":"closed"},{"kind":"pr","number":36058,"title":"fix(mcp): fail fast on HTML content-type instead of waiting full connect_timeout","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#37133","number":37133}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":36064,"title":"fix(mcp): fail fast on non-MCP HTTP content type instead of waiting 60s","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#37133","number":37133}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":37133,"title":"fix(mcp): fail fast and non-retryably on non-MCP HTTP endpoints","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_mcp_preflight_content_type.py","tools/mcp_tool.py"],"fix_hunks":[{"file":"tests/tools/test_mcp_preflight_content_type.py","hunks":["@@ -0,0 +1,237 @@\n+\"\"\"Tests for MCPServerTask._preflight_content_type fast-fail behaviour.\n+\n+These drive the REAL ``_preflight_content_type`` method against a real local\n+HTTP server (via httpx's ASGI/transport plumbing through a stdlib server),\n+rather than reimplementing the probe inline. That distinction matters: the\n+production probe must run on its own httpx client outside the MCP SDK's anyio\n+task group, and a faithful test must exercise that actual method so the\n+content-type allow-list, HEAD->GET fallback, and best-effort pass-through are\n+all covered as shipped.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import http.server\n+import socketserver\n+import threading\n+from contextlib import contextmanager\n+\n+import pytest\n+\n+from tools.mcp_tool import MCPServerTask, NonMcpEndpointError\n+\n+\n+def _make_task(name: str = \"probe_srv\") -> MCPServerTask:\n+    \"\"\"Minimal MCPServerTask without running the heavy __init__.\"\"\"\n+    task = MCPServerTask.__new__(MCPServerTask)\n+    task.name = name\n+    return task\n+\n+\n+@contextmanager\n+def _serve(handler_cls):\n+    \"\"\"Run *handler_cls* on a background thread; yield its base URL.\"\"\"\n+    httpd = socketserver.TCPServer((\"127.0.0.1\", 0), handler_cls)\n+    port = httpd.server_address[1]\n+    t = threading.Thread(target=httpd.serve_forever, daemon=True)\n+    t.start()\n+    try:\n+        yield f\"http://127.0.0.1:{port}\"\n+    finally:\n+        httpd.shutdown()\n+        httpd.server_close()\n+        t.join(timeout=5)\n+\n+\n+def _handler(status: int = 200,\n+             content_type: \"str | None\" = \"text/html; charset=utf-8\",\n+             body: bytes = b\"<html>x</html>\", head_status=None, record=None):\n+    \"\"\"Build a BaseHTTPRequestHandler that replies with the given shape.\n+\n+    ``head_status`` lets HEAD return a different status than GET (to exercise\n+    the HEAD->GET fallback). ``record`` is an optional list that captures the\n+    HTTP methods the server actually saw.\n+    \"\"\"\n+\n+    class _H(http.server.BaseHTTPRequestHandler):\n+        def _write(self, sc, ct, payload):\n+            self.send_response(sc)\n+            if ct is not None:\n+                self.send_header(\"Content-Type\", ct)\n+            self.send_header(\"Content-Length\", str(len(payload)))\n+            self.end_headers()\n+            if payload:\n+                self.wfile.write(payload)\n+\n+        def do_HEAD(self):\n+            if record is not None:\n+                record.append(\"HEAD\")\n+            sc = head_status if head_status is not None else status\n+            self._write(sc, content_type, b\"\")\n+\n+        def do_GET(self):\n+            if record is not None:\n+                record.append(\"GET\")\n+            self._write(status, content_type, body)\n+\n+        def log_message(self, format, *args):  # noqa: A002\n+            pass\n+\n+    return _H\n+\n+\n+# ---------------------------------------------------------------------------\n+# Reject: non-MCP content types on a 2xx response\n+# ---------------------------------------------------------------------------\n+\n[redacted:email](\"content_type\", [\n+    \"text/html; charset=utf-8\",\n+    \"text/html\",\n+    \"text/plain\",\n+    \"application/xml\",\n+    \"text/HTML\",  # case-insensitivity\n+])\n+def test_non_mcp_content_type_raises(content_type):\n+    task = _make_task(\"bad_srv\")\n+    with _serve(_handler(status=200, content_type=content_type)) as base:\n+        with pytest.raises(NonMcpEndpointError) as exc_info:\n+            asyncio.run(task._preflight_content_type(f\"{base}/\", timeout=5.0))\n+    msg = str(exc_info.value)\n+    assert \"bad_srv\" in msg\n+    assert \"application/json\" in msg and \"text/event-stream\" in msg\n+\n+\n+def test_non_mcp_error_is_non_retryable_connection_error():\n+    \"\"\"NonMcpEndpointError must subclass ConnectionError (retry loop skips it\n+    via an explicit except; broad ConnectionError catchers still work).\"\"\"\n+    assert issubclass(NonMcpEndpointError, ConnectionError)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Pass-through: valid MCP content types, ambiguous, and error responses\n+# ---------------------------------------------------------------------------\n+\n[redacted:email](\"content_type\", [\n+    \"application/json\",\n+    \"application/json; charset=utf-8\",\n+    \"text/event-stream\",\n+    \"TEXT/EVENT-STREAM\",\n+])\n+def test_valid_mcp_content_types_pass(content_type):\n+    task = _make_task()\n+    with _serve(_handler(status=200, content_type=content_type, body=b\"{}\")) as base:\n+        # Must not raise.\n+        asyncio.run(task._preflight_content_type(f\"{base}/mcp\", timeout=5.0))\n+\n+\n+def test_missing_content_type_passes():\n+    task = _make_task()\n+    with _serve(_handler(status=200, content_type=None, body=b\"\")) as base:\n+        asyncio.run(task._preflight_content_type(f\"{base}/mcp\", timeout=5.0))\n+\n+\n[redacted:email](\"status\", [401, 403, 404, 500, 503])\n+def test_non_2xx_responses_pass(status):\n+    \"\"\"4xx/5xx are auth challenges or transient errors — let the SDK handle.\"\"\"\n+    task = _make_task()\n+    with _serve(_handler(status=status, content_type=\"text/html\")) as base:\n+        asyncio.run(task._preflight_content_type(f\"{base}/mcp\", timeout=5.0))\n+\n+\n+def test_network_error_passes():\n+    \"\"\"A connection failure (nothing listening) must pass through, not raise.\"\"\"\n+    task = _make_task()\n+    # Reserve a port then close it so the connection is refused.\n+    s = socketserver.TCPServer((\"127.0.0.1\", 0), http.server.BaseHTTPRequestHandler)\n+    dead_port = s.server_address[1]\n+    s.server_close()\n+    asyncio.run(\n+        task._preflight_content_type(\n+            f\"http://127.0.0.1:{dead_port}/mcp\", timeout=2.0\n+        )\n+    )\n+\n+\n+def test_cancelled_error_is_not_swallowed():\n+    \"\"\"The best-effort except must NOT catch CancelledError (BaseException).\"\"\"\n+    task = _make_task()\n+\n+    async def _run():\n+        import httpx\n+        orig = httpx.AsyncClient\n+        try:\n+            # Patch the client so entering it raises CancelledError.\n+            class _C(orig):\n+                async def __aenter__(self):\n+                    raise asyncio.CancelledError()\n+\n+            httpx.AsyncClient = _C\n+            with pytest.raises(asyncio.CancelledError):\n+                await task._preflight_content_type(\"http://x/mcp\", timeout=1.0)\n+        finally:\n+            httpx.AsyncClient = orig\n+\n+    asyncio.run(_run())\n+\n+\n+# ---------------------------------------------------------------------------\n+# HEAD -> GET fallback\n+# ---------------------------------------------------------------------------\n+\n+def test_head_405_falls_back_to_get_and_rejects_html():\n+    task = _make_task(\"fallback_srv\")\n+    record: list[str] = []\n+    with _serve(_handler(\n+        status=200, content_type=\"text/html\",\n+        head_status=405, record=record,\n+    )) as base:\n+        with pytest.raises(NonMcpEndpointError):\n+            asyncio.run(task._preflight_content_type(f\"{base}/\", timeout=5.0))\n+    assert record == [\"HEAD\", \"GET\"]\n+\n+\n+def test_head_501_falls_back_to_get_and_passes_json():\n+    task = _make_task()\n+    record: list[str] = []\n+    with _serve(_handler(\n+        status=200, content_type=\"application/json\", body=b\"{}\",\n+        head_status=501, record=record,\n+    )) as base:\n+        asyncio.run(task._preflight_content_type(f\"{base}/mcp\", timeout=5.0))\n+    assert record == [\"HEAD\", \"GET\"]\n+\n+\n+# ---------------------------------------------------------------------------\n+# ssl_verify / client_cert forwarding to the probe client\n+# ---------------------------------------------------------------------------\n+\n+def test_ssl_verify_and_cert_forwarded(monkeypatch):\n+    captured: dict = {}\n+\n+    import httpx\n+\n+    class _FakeClient:\n+        def __init__(self, **kwargs):\n+            captured.update(kwargs)\n+\n+        async def __aenter__(self):\n+            return self\n+\n+        async def __aexit__(self, *a):\n+            return False\n+\n+        async def head(self, url, headers=None):\n+            return httpx.Response(200, headers={\"content-type\": \"application/json\"})\n+\n+    monkeypatch.setattr(httpx, \"AsyncClient\", _FakeClient)\n+    task = _make_task()\n+    asyncio.run(task._preflight_content_type(\n+        \"https://mcp.example.com/mcp\",\n+        ssl_verify=False,\n+        client_cert=\"/path/to/cert.pem\",\n+        timeout=3.0,\n+    ))\n+    assert captured.get(\"verify\") is False\n+    assert captured.get(\"cert\") == \"/path/to/cert.pem\"\n+    assert captured.get(\"follow_redirects\") is True"]},{"file":"tools/mcp_tool.py","hunks":["@@ -518,6 +518,21 @@ class InvalidMcpUrlError(ValueError):\n     \"\"\"\n \n \n+class NonMcpEndpointError(ConnectionError):\n+    \"\"\"Raised when an HTTP MCP URL serves a non-MCP response.\n+\n+    A genuine MCP Streamable-HTTP endpoint answers with ``application/json``\n+    or ``text/event-stream``.  Anything else on a 2xx response (typically\n+    ``text/html`` from a web-app root) means the configured ``url`` points at\n+    the wrong place.  This is non-retryable: every attempt returns the same\n+    page, so the reconnect-backoff loop is skipped and the server is reported\n+    failed immediately with an actionable message.\n+\n+    Subclasses :class:`ConnectionError` so callers that only catch the broad\n+    class still treat it as a connection problem.\n+    \"\"\"\n+\n+\n def _validate_remote_mcp_url(server_name: str, url: Any) -> str:\n     \"\"\"Return the URL as a string if it's a valid http(s) remote MCP URL.\n ","@@ -1457,6 +1472,86 @@ async def _run_stdio(self, config: dict):\n                             # PID-reuse can't surface stale pgroup state later.\n                             _stdio_pgids.pop(pid, None)\n \n+    # Content types a real MCP Streamable-HTTP endpoint may return on the\n+    # initial POST/GET. Anything else on a 2xx response means the URL is not\n+    # an MCP endpoint.\n+    _MCP_CONTENT_TYPES = (\"application/json\", \"text/event-stream\")\n+\n+    async def _preflight_content_type(\n+        self,\n+        url: str,\n+        *,\n+        headers: Optional[dict] = None,\n+        ssl_verify: bool = True,\n+        client_cert=None,\n+        timeout: float = 5.0,\n+    ) -> None:\n+        \"\"\"Probe *url* for an MCP-shaped response before the SDK connects.\n+\n+        A misconfigured ``mcp_servers.<name>.url`` pointed at a plain web app\n+        returns HTML (or some other non-MCP body). The MCP SDK then sits on\n+        the connection for the full ``connect_timeout`` (default 60 s) before\n+        surfacing an opaque ``CancelledError``. A cheap, short-timeout probe\n+        here catches that in ≤ ``timeout`` seconds and raises\n+        :class:`NonMcpEndpointError` with an actionable message.\n+\n+        Detection is allow-list based: a 2xx response is rejected only when it\n+        carries a definite content type that is NOT one an MCP endpoint uses\n+        (``application/json`` / ``text/event-stream``). A missing or empty\n+        content type, non-2xx status, or any network/transport error passes\n+        through silently — the probe is strictly best-effort, and the real\n+        handshake remains the source of truth for everything except the\n+        unambiguous \"this is a web page, not MCP\" case.\n+\n+        Runs on its own httpx client OUTSIDE the SDK's anyio task group, so the\n+        raised error propagates as itself rather than being wrapped in an\n+        ``ExceptionGroup`` (which is what defeats hooks installed inside the\n+        SDK transport).\n+        \"\"\"\n+        try:\n+            import httpx as _httpx\n+        except ImportError:\n+            return  # No httpx → skip probe; SDK import would have failed first.\n+\n+        client_kwargs: dict = {\n+            \"verify\": ssl_verify,\n+            \"follow_redirects\": True,\n+            \"timeout\": _httpx.Timeout(timeout),\n+        }\n+        if client_cert is not None:\n+            client_kwargs[\"cert\"] = client_cert\n+\n+        probe_headers = dict(headers) if headers else {}\n+        try:\n+            async with _httpx.AsyncClient(**client_kwargs) as client:\n+                # HEAD is cheapest; fall back to GET if the server doesn't\n+                # implement it (405 Method Not Allowed / 501 Not Implemented).\n+                resp = await client.head(url, headers=probe_headers)\n+                if resp.status_code in (405, 501):\n+                    resp = await client.get(url, headers=probe_headers)\n+        except _httpx.HTTPError:\n+            return  # DNS/connect/timeout/transport error — let the SDK try.\n+\n+        # Only judge successful responses. A 4xx/5xx may be an auth challenge\n+        # or a transient error the real handshake handles correctly.\n+        if not (200 <= resp.status_code < 300):\n+            return\n+\n+        ct_base = resp.headers.get(\"content-type\", \"\").split(\";\")[0].strip().lower()\n+        if not ct_base:\n+            return  # No content type advertised — don't second-guess the SDK.\n+        if ct_base in self._MCP_CONTENT_TYPES:\n+            return  # Looks like a real MCP endpoint.\n+\n+        raise NonMcpEndpointError(\n+            f\"MCP server '{self.name}' at {url} returned Content-Type \"\n+            f\"'{ct_base}', not an MCP response (expected one of: \"\n+            f\"{', '.join(self._MCP_CONTENT_TYPES)}). The URL most likely \"\n+            \"points at a web page rather than an MCP endpoint — check it \"\n+            \"resolves to a Streamable HTTP / SSE endpoint \"\n+            \"(e.g. https://host/mcp, not https://host/).\"\n+        )\n+\n     async def _run_http(self, config: dict):\n         \"\"\"Run the server using HTTP/StreamableHTTP transport.\"\"\"\n         if not _MCP_HTTP_AVAILABLE:","@@ -1698,6 +1793,28 @@ async def run(self, config: dict):\n                 self._ready.set()\n                 return\n \n+            # Pre-flight content-type probe (Streamable HTTP only; SSE is\n+            # exercised by its own client and legitimately serves\n+            # text/event-stream). A URL pointed at a web-app root returns\n+            # HTML, which makes the SDK hang for the full connect_timeout\n+            # before surfacing an opaque CancelledError. Probing here — once,\n+            # outside the SDK task group — fails fast and non-retryably with\n+            # an actionable message, mirroring the URL-validation path above.\n+            if config.get(\"transport\") != \"sse\":\n+                try:\n+                    _probe_headers = dict(config.get(\"headers\") or {})\n+                    await self._preflight_content_type(\n+                        config[\"url\"],\n+                        headers=_probe_headers,\n+                        ssl_verify=config.get(\"ssl_verify\", True),\n+                        client_cert=_resolve_client_cert(self.name, config),\n+                    )\n+                except NonMcpEndpointError as exc:\n+                    logger.warning(\"%s\", exc)\n+                    self._error = exc\n+                    self._ready.set()\n+                    return\n+\n         retries = 0\n         initial_retries = 0\n         backoff = 1.0\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}