{"repo":"NousResearch/hermes-agent","public_id":"497","display_label":"MCP OAuth auth flows and HTTP preflight issues","summary":"These items concern implementing and fixing MCP OAuth 2.1 authentication (PKCE and device code flows) for HTTP transport, alongside recurring problems with the HTTP preflight content-type check incorrectly rejecting valid MCP servers or failing to trigger auth when needed.","subsystem":"hermes_cli","counts":{"members":18,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":6,"merged_prs":3,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2465},"resolution":{"state":"partially_resolved","by":"pr-2465,pr-34807,pr-59316"},"freshness":{"state":"verified","last_verified_at":"2026-07-06T04:36:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.08836601307189543},"fix_edges":[{"issue_number":497,"pr_number":2465,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34775,"pr_number":34807,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":52460,"pr_number":59316,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57958,"pr_number":59316,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":497,"title":"Feature: MCP OAuth 2.1 PKCE Authentication for HTTP Transport","url":null,"state":"closed"},{"kind":"issue","number":34669,"title":"feat: streamline GitHub MCP OAuth device code flow","url":null,"state":"closed"},{"kind":"issue","number":34676,"title":"GitHub MCP OAuth: standard PKCE flow doesn't work, device code flow is fragile workaround","url":null,"state":"closed"},{"kind":"issue","number":34775,"title":"[Bug]: Official MCP server for Google Drive authenticates successfully, then always times out for any tool","url":null,"state":"closed"},{"kind":"issue","number":51535,"title":"[Bug]: OAuth MCP connection fails with 405 Method Not Allowed when adding server with --auth oauth","url":null,"state":"open"},{"kind":"issue","number":52460,"title":"MCP: Streamable HTTP OAuth servers fail preflight check when GET redirects","url":null,"state":"closed"},{"kind":"issue","number":53870,"title":"`hermes mcp login` never triggers OAuth when the MCP server allows `initialize` without auth (e.g. Blynk)","url":null,"state":"open"},{"kind":"issue","number":57958,"title":"[Bug]: MCP HTTP Preflight check fails when the server only implements the MCP POST requests","url":null,"state":"closed"},{"kind":"pr","number":2465,"title":"feat(cli): MCP server management CLI + OAuth 2.1 PKCE auth","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","hermes_cli/mcp_config.py","tests/hermes_cli/test_mcp_config.py","tests/tools/test_mcp_oauth.py","tools/mcp_oauth.py","tools/mcp_tool.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -2958,7 +2958,7 @@ def _coalesce_session_name_args(argv: list) -> list:\n     _SUBCOMMANDS = {\n         \"chat\", \"model\", \"gateway\", \"setup\", \"whatsapp\", \"login\", \"logout\",\n         \"status\", \"cron\", \"doctor\", \"config\", \"pairing\", \"skills\", \"tools\",\n-        \"sessions\", \"insights\", \"version\", \"update\", \"uninstall\",\n+        \"mcp\", \"sessions\", \"insights\", \"version\", \"update\", \"uninstall\",\n     }\n     _SESSION_FLAGS = {\"-c\", \"--continue\", \"-r\", \"--resume\"}\n ","@@ -3702,6 +3702,45 @@ def cmd_tools(args):\n             tools_command(args)\n \n     tools_parser.set_defaults(func=cmd_tools)\n+    # =========================================================================\n+    # mcp command — manage MCP server connections\n+    # =========================================================================\n+    mcp_parser = subparsers.add_parser(\n+        \"mcp\",\n+        help=\"Manage MCP server connections\",\n+        description=(\n+            \"Add, remove, list, test, and configure MCP server connections.\\n\\n\"\n+            \"MCP servers provide additional tools via the Model Context Protocol.\\n\"\n+            \"Use 'hermes mcp add' to connect to a new server with interactive\\n\"\n+            \"tool discovery. Run 'hermes mcp' with no subcommand to list servers.\"\n+        ),\n+    )\n+    mcp_sub = mcp_parser.add_subparsers(dest=\"mcp_action\")\n+\n+    mcp_add_p = mcp_sub.add_parser(\"add\", help=\"Add an MCP server (discovery-first install)\")\n+    mcp_add_p.add_argument(\"name\", help=\"Server name (used as config key)\")\n+    mcp_add_p.add_argument(\"--url\", help=\"HTTP/SSE endpoint URL\")\n+    mcp_add_p.add_argument(\"--command\", help=\"Stdio command (e.g. npx)\")\n+    mcp_add_p.add_argument(\"--args\", nargs=\"*\", default=[], help=\"Arguments for stdio command\")\n+    mcp_add_p.add_argument(\"--auth\", choices=[\"oauth\", \"header\"], help=\"Auth method\")\n+\n+    mcp_rm_p = mcp_sub.add_parser(\"remove\", aliases=[\"rm\"], help=\"Remove an MCP server\")\n+    mcp_rm_p.add_argument(\"name\", help=\"Server name to remove\")\n+\n+    mcp_sub.add_parser(\"list\", aliases=[\"ls\"], help=\"List configured MCP servers\")\n+\n+    mcp_test_p = mcp_sub.add_parser(\"test\", help=\"Test MCP server connection\")\n+    mcp_test_p.add_argument(\"name\", help=\"Server name to test\")\n+\n+    mcp_cfg_p = mcp_sub.add_parser(\"configure\", aliases=[\"config\"], help=\"Toggle tool selection\")\n+    mcp_cfg_p.add_argument(\"name\", help=\"Server name to configure\")\n+\n+    def cmd_mcp(args):\n+        from hermes_cli.mcp_config import mcp_command\n+        mcp_command(args)\n+\n+    mcp_parser.set_defaults(func=cmd_mcp)\n+\n     # =========================================================================\n     # sessions command\n     # ========================================================================="]},{"file":"hermes_cli/mcp_config.py","hunks":["@@ -0,0 +1,635 @@\n+\"\"\"\n+MCP Server Management CLI — ``hermes mcp`` subcommand.\n+\n+Implements ``hermes mcp add/remove/list/test/configure`` for interactive\n+MCP server lifecycle management (issue #690 Phase 2).\n+\n+Relies on tools/mcp_tool.py for connection/discovery and keeps\n+configuration in ~/.hermes/config.yaml under the ``mcp_servers`` key.\n+\"\"\"\n+\n+import asyncio\n+import getpass\n+import logging\n+import os\n+import re\n+import time\n+from pathlib import Path\n+from typing import Any, Dict, List, Optional, Set, Tuple\n+\n+from hermes_cli.config import (\n+    load_config,\n+    save_config,\n+    get_env_value,\n+    save_env_value,\n+    get_hermes_home,\n+)\n+from hermes_cli.colors import Colors, color\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+# ─── UI Helpers ───────────────────────────────────────────────────────────────\n+\n+def _info(text: str):\n+    print(color(f\"  {text}\", Colors.DIM))\n+\n+def _success(text: str):\n+    print(color(f\"  ✓ {text}\", Colors.GREEN))\n+\n+def _warning(text: str):\n+    print(color(f\"  ⚠ {text}\", Colors.YELLOW))\n+\n+def _error(text: str):\n+    print(color(f\"  ✗ {text}\", Colors.RED))\n+\n+\n+def _confirm(question: str, default: bool = True) -> bool:\n+    default_str = \"Y/n\" if default else \"y/N\"\n+    try:\n+        val = input(color(f\"  {question} [{default_str}]: \", Colors.YELLOW)).strip().lower()\n+    except (KeyboardInterrupt, EOFError):\n+        print()\n+        return default\n+    if not val:\n+        return default\n+    return val in (\"y\", \"yes\")\n+\n+\n+def _prompt(question: str, *, password: bool = False, default: str = \"\") -> str:\n+    display = f\"  {question}\"\n+    if default:\n+        display += f\" [{default}]\"\n+    display += \": \"\n+    try:\n+        if password:\n+            value = getpass.getpass(color(display, Colors.YELLOW))\n+        else:\n+            value = input(color(display, Colors.YELLOW))\n+        return value.strip() or default\n+    except (KeyboardInterrupt, EOFError):\n+        print()\n+        return default\n+\n+\n+# ─── Config Helpers ───────────────────────────────────────────────────────────\n+\n+def _get_mcp_servers(config: Optional[dict] = None) -> Dict[str, dict]:\n+    \"\"\"Return the ``mcp_servers`` dict from config, or empty dict.\"\"\"\n+    if config is None:\n+        config = load_config()\n+    servers = config.get(\"mcp_servers\")\n+    if not servers or not isinstance(servers, dict):\n+        return {}\n+    return servers\n+\n+\n+def _save_mcp_server(name: str, server_config: dict):\n+    \"\"\"Add or update a server entry in config.yaml.\"\"\"\n+    config = load_config()\n+    config.setdefault(\"mcp_servers\", {})[name] = server_config\n+    save_config(config)\n+\n+\n+def _remove_mcp_server(name: str) -> bool:\n+    \"\"\"Remove a server from config.yaml.  Returns True if it existed.\"\"\"\n+    config = load_config()\n+    servers = config.get(\"mcp_servers\", {})\n+    if name not in servers:\n+        return False\n+    del servers[name]\n+    if not servers:\n+        config.pop(\"mcp_servers\", None)\n+    save_config(config)\n+    return True\n+\n+\n+def _env_key_for_server(name: str) -> str:\n+    \"\"\"Convert server name to an env-var key like ``MCP_MYSERVER_API_KEY``.\"\"\"\n+    return f\"MCP_{name.upper().replace('-', '_')}_API_KEY\"\n+\n+\n+# ─── Discovery (temporary connect) ───────────────────────────────────────────\n+\n+def _probe_single_server(\n+    name: str, config: dict, connect_timeout: float = 30\n+) -> List[Tuple[str, str]]:\n+    \"\"\"Temporarily connect to one MCP server, list its tools, disconnect.\n+\n+    Returns list of ``(tool_name, description)`` tuples.\n+    Raises on connection failure.\n+    \"\"\"\n+    from tools.mcp_tool import (\n+        _ensure_mcp_loop,\n+        _run_on_mcp_loop,\n+        _connect_server,\n+        _stop_mcp_loop,\n+    )\n+\n+    _ensure_mcp_loop()\n+\n+    tools_found: List[Tuple[str, str]] = []\n+\n+    async def _probe():\n+        server = await asyncio.wait_for(\n+            _connect_server(name, config), timeout=connect_timeout\n+        )\n+        for t in server._tools:\n+            desc = getattr(t, \"description\", \"\") or \"\"\n+            # Truncate long descriptions for display\n+            if len(desc) > 80:\n+                desc = desc[:77] + \"...\"\n+            tools_found.append((t.name, desc))\n+        await server.shutdown()\n+\n+    try:\n+        _run_on_mcp_loop(_probe(), timeout=connect_timeout + 10)\n+    except BaseException as exc:\n+        raise _unwrap_exception_group(exc) from None\n+    finally:\n+        _stop_mcp_loop()\n+\n+    return tools_found\n+\n+\n+def _unwrap_exception_group(exc: BaseException) -> Exception:\n+    \"\"\"Extract the root-cause exception from anyio TaskGroup wrappers.\n+\n+    The MCP SDK uses anyio task groups, which wrap errors in\n+    ``BaseExceptionGroup`` / ``ExceptionGroup``.  This makes error\n+    messages opaque (\"unhandled errors in a TaskGroup\").  We unwrap\n+    to surface the real cause (e.g. \"401 Unauthorized\").\n+    \"\"\"\n+    while isinstance(exc, BaseExceptionGroup) and exc.exceptions:\n+        exc = exc.exceptions[0]\n+    # Return a plain Exception so callers can catch normally\n+    if isinstance(exc, Exception):\n+        return exc\n+    return RuntimeError(str(exc))\n+\n+\n+# ─── hermes mcp add ──────────────────────────────────────────────────────────\n+\n+def cmd_mcp_add(args):\n+    \"\"\"Add a new MCP server with discovery-first tool selection.\"\"\"\n+    name = args.name\n+    url = getattr(args, \"url\", None)\n+    command = getattr(args, \"command\", None)\n+    cmd_args = getattr(args, \"args\", None) or []\n+    auth_type = getattr(args, \"auth\", None)\n+\n+    # Validate transport\n+    if not url and not command:\n+        _error(\"Must specify --url <endpoint> or --command <cmd>\")\n+        _info(\"Examples:\")\n+        _info('  hermes mcp add ink --url \"https://mcp.ml.ink/mcp\"')\n+        _info('  hermes mcp add github --command npx --args @modelcontextprotocol/server-github')\n+        return\n+\n+    # Check if server already exists\n+    existing = _get_mcp_servers()\n+    if name in existing:\n+        if not _confirm(f\"Server '{name}' already exists. Overwrite?\", default=False):\n+            _info(\"Cancelled.\")\n+            return\n+\n+    # Build initial config\n+    server_config: Dict[str, Any] = {}\n+    if url:\n+        server_config[\"url\"] = url\n+    else:\n+        server_config[\"command\"] = command\n+        if cmd_args:\n+            server_config[\"args\"] = cmd_args\n+\n+    # ── Authentication ────────────────────────────────────────────────\n+\n+    if url and auth_type == \"oauth\":\n+        print()\n+        _info(f\"Starting OAuth flow for '{name}'...\")\n+        oauth_ok = False\n+        try:\n+            from tools.mcp_oauth import build_oauth_auth\n+            oauth_auth = build_oauth_auth(name, url)\n+            if oauth_auth:\n+                server_config[\"auth\"] = \"oauth\"\n+                _success(\"OAuth configured (tokens will be acquired on first connection)\")\n+                oauth_ok=True\n+            else:\n+                _warning(\"OAuth setup failed — MCP SDK auth module not available\")\n+        except Exception as exc:\n+            _warning(f\"OAuth error: {exc}\")\n+\n+        if not oauth_ok:\n+            _info(\"This server may not support OAuth.\")\n+            if _confirm(\"Continue without authentication?\", default=True):\n+                # Don't store auth: oauth — server doesn't support it\n+                pass\n+            else:\n+                _info(\"Cancelled.\")\n+                return\n+\n+    elif url:\n+        # Prompt for API key / Bearer token for HTTP servers\n+        print()\n+        _info(f\"Connecting to {url}\")\n+        needs_auth = _confirm(\"Does this server require authentication?\", default=True)\n+        if needs_auth:\n+            if auth_type == \"header\" or not auth_type:\n+                env_key = _env_key_for_server(name)\n+                existing_key = get_env_value(env_key)\n+                if existing_key:\n+                    _success(f\"{env_key}: already configured\")\n+                    api_key = [redacted:secret-assignment]\n+                else:\n+                    api_key = [redacted:secret-assignment]\"API key / Bearer token\", password=True)\n+                    if api_key:\n+                        save_env_value(env_key, api_key)\n+                        _success(f\"Saved to ~/.hermes/.env as {env_key}\")\n+\n+                # Set header with env var interpolation\n+                if api_key or existing_key:\n+                    server_config[\"headers\"] = {\n+                        \"Authorization\": f\"Bearer ${{{env_key}}}\"\n+                    }\n+\n+    # ── Discovery: connect and list tools ─────────────────────────────\n+\n+    print()\n+    print(color(f\"  Connecting to '{name}'...\", Colors.CYAN))\n+\n+    try:\n+        tools = _probe_single_server(name, server_config)\n+    except Exception as exc:\n+        _error(f\"Failed to connect: {exc}\")\n+        if _confirm(\"Save config anyway (you can test later)?\", default=False):\n+            server_config[\"enabled\"] = False\n+            _save_mcp_server(name, server_config)\n+            _success(f\"Saved '{name}' to config (disabled)\")\n+            _info(\"Fix the issue, then: hermes mcp test \" + name)\n+        return\n+\n+    if not tools:\n+        _warning(\"Server connected but reported no tools.\")\n+        if _confirm(\"Save config anyway?\", default=True):\n+            _save_mcp_server(name, server_config)\n+            _success(f\"Saved '{name}' to config\")\n+        return\n+\n+    # ── Tool selection ────────────────────────────────────────────────\n+\n+    print()\n+    _success(f\"Connected! Found {len(tools)} tool(s) from '{name}':\")\n+    print()\n+    for tool_name, desc in tools:\n+        short = desc[:60] + \"...\" if len(desc) > 60 else desc\n+        print(f\"    {color(tool_name, Colors.GREEN):40s} {short}\")\n+    print()\n+\n+    # Ask: enable all, select, or cancel\n+    try:\n+        choice = input(\n+            color(f\"  Enable all {len(tools)} tools? [Y/n/select]: \", Colors.YELLOW)\n+        ).strip().lower()\n+    except (KeyboardInterrupt, EOFError):\n+        print()\n+        _info(\"Cancelled.\")\n+        return\n+\n+    if choice in (\"n\", \"no\"):\n+        _info(\"Cancelled — server not saved.\")\n+        return\n+\n+    if choice in (\"s\", \"select\"):\n+        # Interactive tool selection\n+        from hermes_cli.curses_ui import curses_checklist\n+\n+        labels = [f\"{t[0]}  —  {t[1]}\" for t in tools]\n+        pre_selected = set(range(len(tools)))\n+\n+        chosen = curses_checklist(\n+            f\"Select tools for '{name}'\",\n+            labels,\n+            pre_selected,\n+        )\n+\n+        if not chosen:\n+            _info(\"No tools selected — server not saved.\")\n+            return\n+\n+        chosen_names = [tools[i][0] for i in sorted(chosen)]\n+        server_config.setdefault(\"tools\", {})[\"include\"] = chosen_names\n+\n+        tool_count = len(chosen_names)\n+        total = len(tools)\n+    else:\n+        # Enable all (no filter needed — default behaviour)\n+        tool_count = len(tools)\n+        total = len(tools)\n+\n+    # ── Save ──────────────────────────────────────────────────────────\n+\n+    server_config[\"enabled\"] = True\n+    _save_mcp_server(name, server_config)\n+\n+    print()\n+    _success(f\"Saved '{name}' to ~/.hermes/config.yaml ({tool_count}/{total} tools enabled)\")\n+    _info(\"Start a new session to use these tools.\")\n+\n+\n+# ─── hermes mcp remove ───────────────────────────────────────────────────────\n+\n+def cmd_mcp_remove(args):\n+    \"\"\"Remove an MCP server from config.\"\"\"\n+    name = args.name\n+    existing = _get_mcp_servers()\n+\n+    if name not in existing:\n+        _error(f\"Server '{name}' not found in config.\")\n+        servers = list(existing.keys())\n+        if servers:\n+            _info(f\"Available servers: {', '.join(servers)}\")\n+        return\n+\n+    if not _confirm(f\"Remove server '{name}'?\", default=True):\n+        _info(\"Cancelled.\")\n+        return\n+\n+    _remove_mcp_server(name)\n+    _success(f\"Removed '{name}' from config\")\n+\n+    # Clean up OAuth tokens if they exist\n+    try:\n+        from tools.mcp_oauth import remove_oauth_tokens\n+        remove_oauth_tokens(name)\n+        _success(\"Cleaned up OAuth tokens\")\n+    except Exception:\n+        pass\n+\n+\n+# ─── hermes mcp list ──────────────────────────────────────────────────────────\n+\n+def cmd_mcp_list(args=None):\n+    \"\"\"List all configured MCP servers.\"\"\"\n+    servers = _get_mcp_servers()\n+\n+    if not servers:\n+        print()\n+        _info(\"No MCP servers configured.\")\n+        print()\n+        _info(\"Add one with:\")\n+        _info('  hermes mcp add <name> --url <endpoint>')\n+        _info('  hermes mcp add <name> --command <cmd> --args <args...>')\n+        print()\n+        return\n+\n+    print()\n+    print(color(\"  MCP Servers:\", Colors.CYAN + Colors.BOLD))\n+    print()\n+\n+    # Table header\n+    print(f\"  {'Name':<16} {'Transport':<30} {'Tools':<12} {'Status':<10}\")\n+    print(f\"  {'─' * 16} {'─' * 30} {'─' * 12} {'─' * 10}\")\n+\n+    for name, cfg in servers.items():\n+        # Transport info\n+        if \"url\" in cfg:\n+            url = cfg[\"url\"]\n+            # Truncate long URLs\n+            if len(url) > 28:\n+                url = url[:25] + \"...\"\n+            transport = url\n+        elif \"command\" in cfg:\n+            cmd = cfg[\"command\"]\n+            cmd_args = cfg.get(\"args\", [])\n+            if isinstance(cmd_args, list) and cmd_args:\n+                transport = f\"{cmd} {' '.join(str(a) for a in cmd_args[:2])}\"\n+            else:\n+                transport = cmd\n+            if len(transport) > 28:\n+                transport = transport[:25] + \"...\"\n+        else:\n+            transport = \"?\"\n+\n+        # Tool count\n+        tools_cfg = cfg.get(\"tools\", {})\n+        if isinstance(tools_cfg, dict):\n+            include = tools_cfg.get(\"include\")\n+            exclude = tools_cfg.get(\"exclude\")\n+            if include and isinstance(include, list):\n+                tools_str = f\"{len(include)} selected\"\n+            elif exclude and isinstance(exclude, list):\n+                tools_str = f\"-{len(exclude)} excluded\"\n+            else:\n+                tools_str = \"all\"\n+        else:\n+            tools_str = \"all\"\n+\n+        # Enabled status\n+        enabled = cfg.get(\"enabled\", True)\n+        if isinstance(enabled, str):\n+            enabled = enabled.lower() in (\"true\", \"1\", \"yes\")\n+        status = color(\"✓ enabled\", Colors.GREEN) if enabled else color(\"✗ disabled\", Colors.DIM)\n+\n+        print(f\"  {name:<16} {transport:<30} {tools_str:<12} {status}\")\n+\n+    print()\n+\n+\n+# ─── hermes mcp test ──────────────────────────────────────────────────────────\n+\n+def cmd_mcp_test(args):\n+    \"\"\"Test connection to an MCP server.\"\"\"\n+    name = args.name\n+    servers = _get_mcp_servers()\n+\n+    if name not in servers:\n+        _error(f\"Server '{name}' not found in config.\")\n+        available = list(servers.keys())\n+        if available:\n+            _info(f\"Available: {', '.join(available)}\")\n+        return\n+\n+    cfg = servers[name]\n+    print()\n+    print(color(f\"  Testing '{name}'...\", Colors.CYAN))\n+\n+    # Show transport info\n+    if \"url\" in cfg:\n+        _info(f\"Transport: HTTP → {cfg['url']}\")\n+    else:\n+        cmd = cfg.get(\"command\", \"?\")\n+        _info(f\"Transport: stdio → {cmd}\")\n+\n+    # Show auth info (masked)\n+    auth_type = cfg.get(\"auth\", \"\")\n+    headers = cfg.get(\"headers\", {})\n+    if auth_type == \"oauth\":\n+        _info(\"Auth: OAuth 2.1 PKCE\")\n+    elif headers:\n+        for k, v in headers.items():\n+            if isinstance(v, str) and (\"key\" in k.lower() or \"auth\" in k.lower()):\n+                # Mask the value\n+                resolved = _interpolate_value(v)\n+                if len(resolved) > 8:\n+                    masked = resolved[:4] + \"***\" + resolved[-4:]\n+                else:\n+                    masked = \"***\"\n+                print(f\"    {k}: {masked}\")\n+    else:\n+        _info(\"Auth: none\")\n+\n+    # Attempt connection\n+    start = time.monotonic()\n+    try:\n+        tools = _probe_single_server(name, cfg)\n+        elapsed_ms = (time.monotonic() - start) * 1000\n+    except Exception as exc:\n+        elapsed_ms = (time.monotonic() - start) * 1000\n+        _error(f\"Connection failed ({elapsed_ms:.0f}ms): {exc}\")\n+        return\n+\n+    _success(f\"Connected ({elapsed_ms:.0f}ms)\")\n+    _success(f\"Tools discovered: {len(tools)}\")\n+\n+    if tools:\n+        print()\n+        for tool_name, desc in tools:\n+            short = desc[:55] + \"...\" if len(desc) > 55 else desc\n+            print(f\"    {color(tool_name, Colors.GREEN):36s} {short}\")\n+    print()\n+\n+\n+def _interpolate_value(value: str) -> str:\n+    \"\"\"Resolve ``${ENV_VAR}`` references in a string.\"\"\"\n+    def _replace(m):\n+        return os.getenv(m.group(1), \"\")\n+    return re.sub(r\"\\$\\{(\\w+)\\}\", _replace, value)\n+\n+\n+# ─── hermes mcp configure ────────────────────────────────────────────────────\n+\n+def cmd_mcp_configure(args):\n+    \"\"\"Reconfigure which tools are enabled for an existing MCP server.\"\"\"\n+    name = args.name\n+    servers = _get_mcp_servers()\n+\n+    if name not in servers:\n+        _error(f\"Server '{name}' not found in config.\")\n+        available = list(servers.keys())\n+        if available:\n+            _info(f\"Available: {', '.join(available)}\")\n+        return\n+\n+    cfg = servers[name]\n+\n+    # Discover all available tools\n+    print()\n+    print(color(f\"  Connecting to '{name}' to discover tools...\", Colors.CYAN))\n+\n+    try:\n+        all_tools = _probe_single_server(name, cfg)\n+    except Exception as exc:\n+        _error(f\"Failed to connect: {exc}\")\n+        return\n+\n+    if not all_tools:\n+        _warning(\"Server reports no tools.\")\n+        return\n+\n+    # Determine which are currently enabled\n+    tools_cfg = cfg.get(\"tools\", {})\n+    if isinstance(tools_cfg, dict):\n+        include = tools_cfg.get(\"include\")\n+        exclude = tools_cfg.get(\"exclude\")\n+    else:\n+        include = None\n+        exclude = None\n+\n+    tool_names = [t[0] for t in all_tools]\n+\n+    if include and isinstance(include, list):\n+        include_set = set(include)\n+        pre_selected = {\n+            i for i, tn in enumerate(tool_names) if tn in include_set\n+        }\n+    elif exclude and isinstance(exclude, list):\n+        exclude_set = set(exclude)\n+        pre_selected = {\n+            i for i, tn in enumerate(tool_names) if tn not in exclude_set\n+        }\n+    else:\n+        pre_selected = set(range(len(all_tools)))\n+\n+    currently = len(pre_selected)\n+    total = len(all_tools)\n+    _info(f\"Currently {currently}/{total} tools enabled for '{name}'.\")\n+    print()\n+\n+    # Interactive checklist\n+    from hermes_cli.curses_ui import curses_checklist\n+\n+    labels = [f\"{t[0]}  —  {t[1]}\" for t in all_tools]\n+\n+    chosen = curses_checklist(\n+        f\"Select tools for '{name}'\",\n+        labels,\n+        pre_selected,\n+    )\n+\n+    if chosen == pre_selected:\n+        _info(\"No changes made.\")\n+        return\n+\n+    # Update config\n+    config = load_config()\n+    server_entry = config.get(\"mcp_servers\", {}).get(name, {})\n+\n+    if len(chosen) == total:\n+        # All selected → remove include/exclude (register all)\n+        server_entry.pop(\"tools\", None)\n+    else:\n+        chosen_names = [tool_names[i] for i in sorted(chosen)]\n+        server_entry.setdefault(\"tools\", {})\n+        server_entry[\"tools\"][\"include\"] = chosen_names\n+        server_entry[\"tools\"].pop(\"exclude\", None)\n+\n+    config.setdefault(\"mcp_servers\", {})[name] = server_entry\n+    save_config(config)\n+\n+    new_count = len(chosen)\n+    _success(f\"Updated config: {new_count}/{total} tools enabled\")\n+    _info(\"Start a new session for changes to take effect.\")\n+\n+\n+# ─── Dispatcher ───────────────────────────────────────────────────────────────\n+\n+def mcp_command(args):\n+    \"\"\"Main dispatcher for ``hermes mcp`` subcommands.\"\"\"\n+    action = getattr(args, \"mcp_action\", None)\n+\n+    handlers = {\n+        \"add\": cmd_mcp_add,\n+        \"remove\": cmd_mcp_remove,\n+        \"rm\": cmd_mcp_remove,\n+        \"list\": cmd_mcp_list,\n+        \"ls\": cmd_mcp_list,\n+        \"test\": cmd_mcp_test,\n+        \"configure\": cmd_mcp_configure,\n+        \"config\": cmd_mcp_configure,\n+    }\n+\n+    handler = handlers.get(action)\n+    if handler:\n+        handler(args)\n+    else:\n+        # No subcommand — show list\n+        cmd_mcp_list()\n+        print(color(\"  Commands:\", Colors.CYAN))\n+        _info(\"hermes mcp add <name> --url <endpoint>        Add an MCP server\")\n+        _info(\"hermes mcp add <name> --command <cmd>         Add a stdio server\")\n+        _info(\"hermes mcp remove <name>                      Remove a server\")\n+        _info(\"hermes mcp list                               List servers\")\n+        _info(\"hermes mcp test <name>                        Test connection\")\n+        _info(\"hermes mcp configure <name>                   Toggle tools\")\n+        print()"]},{"file":"tests/hermes_cli/test_mcp_config.py","hunks":["@@ -0,0 +1,400 @@\n+\"\"\"\n+Tests for hermes_cli.mcp_config — ``hermes mcp`` subcommands.\n+\n+These tests mock the MCP server connection layer so they run without\n+any actual MCP servers or API keys.\n+\"\"\"\n+\n+import argparse\n+import json\n+import os\n+import types\n+from pathlib import Path\n+from typing import Any, Dict, List\n+from unittest.mock import MagicMock, patch, PropertyMock\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# Fixtures\n+# ---------------------------------------------------------------------------\n+\n[redacted:email](autouse=True)\n+def _isolate_config(tmp_path, monkeypatch):\n+    \"\"\"Redirect all config I/O to a temp directory.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    monkeypatch.setattr(\n+        \"hermes_cli.config.get_hermes_home\", lambda: tmp_path\n+    )\n+    config_path = tmp_path / \"config.yaml\"\n+    env_path = tmp_path / \".env\"\n+    monkeypatch.setattr(\n+        \"hermes_cli.config.get_config_path\", lambda: config_path\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.config.get_env_path\", lambda: env_path\n+    )\n+    return tmp_path\n+\n+\n+def _make_args(**kwargs):\n+    \"\"\"Build a minimal argparse.Namespace.\"\"\"\n+    defaults = {\n+        \"name\": \"test-server\",\n+        \"url\": None,\n+        \"command\": None,\n+        \"args\": None,\n+        \"auth\": None,\n+        \"mcp_action\": None,\n+    }\n+    defaults.update(kwargs)\n+    return argparse.Namespace(**defaults)\n+\n+\n+def _seed_config(tmp_path: Path, mcp_servers: dict):\n+    \"\"\"Write a config.yaml with the given mcp_servers.\"\"\"\n+    import yaml\n+\n+    config = {\"mcp_servers\": mcp_servers, \"_config_version\": 9}\n+    config_path = tmp_path / \"config.yaml\"\n+    with open(config_path, \"w\") as f:\n+        yaml.safe_dump(config, f)\n+\n+\n+class FakeTool:\n+    \"\"\"Mimics an MCP tool object returned by the SDK.\"\"\"\n+\n+    def __init__(self, name: str, description: str = \"\"):\n+        self.name = name\n+        self.description = description\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: cmd_mcp_list\n+# ---------------------------------------------------------------------------\n+\n+class TestMcpList:\n+    def test_list_empty_config(self, tmp_path, capsys):\n+        from hermes_cli.mcp_config import cmd_mcp_list\n+\n+        cmd_mcp_list()\n+        out = capsys.readouterr().out\n+        assert \"No MCP servers configured\" in out\n+\n+    def test_list_with_servers(self, tmp_path, capsys):\n+        _seed_config(tmp_path, {\n+            \"ink\": {\n+                \"url\": \"https://mcp.ml.ink/mcp\",\n+                \"enabled\": True,\n+                \"tools\": {\"include\": [\"create_service\", \"get_service\"]},\n+            },\n+            \"github\": {\n+                \"command\": \"npx\",\n+                \"args\": [\"@mcp/github\"],\n+                \"enabled\": False,\n+            },\n+        })\n+        from hermes_cli.mcp_config import cmd_mcp_list\n+\n+        cmd_mcp_list()\n+        out = capsys.readouterr().out\n+        assert \"ink\" in out\n+        assert \"github\" in out\n+        assert \"2 selected\" in out  # ink has 2 in include\n+        assert \"disabled\" in out  # github is disabled\n+\n+    def test_list_enabled_default_true(self, tmp_path, capsys):\n+        \"\"\"Server without explicit enabled key defaults to enabled.\"\"\"\n+        _seed_config(tmp_path, {\n+            \"myserver\": {\"url\": \"https://example.com/mcp\"},\n+        })\n+        from hermes_cli.mcp_config import cmd_mcp_list\n+\n+        cmd_mcp_list()\n+        out = capsys.readouterr().out\n+        assert \"myserver\" in out\n+        assert \"enabled\" in out\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: cmd_mcp_remove\n+# ---------------------------------------------------------------------------\n+\n+class TestMcpRemove:\n+    def test_remove_existing_server(self, tmp_path, capsys, monkeypatch):\n+        _seed_config(tmp_path, {\n+            \"myserver\": {\"url\": \"https://example.com/mcp\"},\n+        })\n+        monkeypatch.setattr(\"builtins.input\", lambda _: \"y\")\n+        from hermes_cli.mcp_config import cmd_mcp_remove\n+\n+        cmd_mcp_remove(_make_args(name=\"myserver\"))\n+\n+        out = capsys.readouterr().out\n+        assert \"Removed\" in out\n+\n+        # Verify config updated\n+        from hermes_cli.config import load_config\n+\n+        config = load_config()\n+        assert \"myserver\" not in config.get(\"mcp_servers\", {})\n+\n+    def test_remove_nonexistent(self, tmp_path, capsys):\n+        _seed_config(tmp_path, {})\n+        from hermes_cli.mcp_config import cmd_mcp_remove\n+\n+        cmd_mcp_remove(_make_args(name=\"ghost\"))\n+        out = capsys.readouterr().out\n+        assert \"not found\" in out\n+\n+    def test_remove_cleans_oauth_tokens(self, tmp_path, capsys, monkeypatch):\n+        _seed_config(tmp_path, {\n+            \"oauth-srv\": {\"url\": \"https://example.com/mcp\", \"auth\": \"oauth\"},\n+        })\n+        monkeypatch.setattr(\"builtins.input\", lambda _: \"y\")\n+        # Also patch get_hermes_home in the mcp_config module namespace\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config.get_hermes_home\", lambda: tmp_path\n+        )\n+\n+        # Create a fake token file\n+        token_dir = [redacted:secret-assignment] / \"mcp-tokens\"\n+        token_dir.mkdir()\n+        token_file = [redacted:secret-assignment] / \"oauth-srv.json\"\n+        token_file.write_text(\"{}\")\n+\n+        from hermes_cli.mcp_config import cmd_mcp_remove\n+\n+        cmd_mcp_remove(_make_args(name=\"oauth-srv\"))\n+        assert not token_file.exists()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: cmd_mcp_add\n+# ---------------------------------------------------------------------------\n+\n+class TestMcpAdd:\n+    def test_add_no_transport(self, capsys):\n+        \"\"\"Must specify --url or --command.\"\"\"\n+        from hermes_cli.mcp_config import cmd_mcp_add\n+\n+        cmd_mcp_add(_make_args(name=\"bad\"))\n+        out = capsys.readouterr().out\n+        assert \"Must specify\" in out\n+\n+    def test_add_http_server_all_tools(self, tmp_path, capsys, monkeypatch):\n+        \"\"\"Add an HTTP server, accept all tools.\"\"\"\n+        fake_tools = [\n+            FakeTool(\"create_service\", \"Deploy from repo\"),\n+            FakeTool(\"list_services\", \"List all services\"),\n+        ]\n+\n+        def mock_probe(name, config, **kw):\n+            return [(t.name, t.description) for t in fake_tools]\n+\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config._probe_single_server\", mock_probe\n+        )\n+        # No auth, accept all tools\n+        inputs = iter([\"n\", \"\"])  # no auth needed, enable all\n+        monkeypatch.setattr(\"builtins.input\", lambda _: next(inputs))\n+\n+        from hermes_cli.mcp_config import cmd_mcp_add\n+\n+        cmd_mcp_add(_make_args(name=\"ink\", url=\"https://mcp.ml.ink/mcp\"))\n+        out = capsys.readouterr().out\n+        assert \"Saved\" in out\n+        assert \"2/2 tools\" in out\n+\n+        # Verify config written\n+        from hermes_cli.config import load_config\n+\n+        config = load_config()\n+        assert \"ink\" in config.get(\"mcp_servers\", {})\n+        assert config[\"mcp_servers\"][\"ink\"][\"url\"] == \"https://mcp.ml.ink/mcp\"\n+\n+    def test_add_stdio_server(self, tmp_path, capsys, monkeypatch):\n+        \"\"\"Add a stdio server.\"\"\"\n+        fake_tools = [FakeTool(\"search\", \"Search repos\")]\n+\n+        def mock_probe(name, config, **kw):\n+            return [(t.name, t.description) for t in fake_tools]\n+\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config._probe_single_server\", mock_probe\n+        )\n+        inputs = iter([\"\"])  # accept all tools\n+        monkeypatch.setattr(\"builtins.input\", lambda _: next(inputs))\n+\n+        from hermes_cli.mcp_config import cmd_mcp_add\n+\n+        cmd_mcp_add(_make_args(\n+            name=\"github\",\n+            command=\"npx\",\n+            args=[\"@mcp/github\"],\n+        ))\n+        out = capsys.readouterr().out\n+        assert \"Saved\" in out\n+\n+        from hermes_cli.config import load_config\n+\n+        config = load_config()\n+        srv = config[\"mcp_servers\"][\"github\"]\n+        assert srv[\"command\"] == \"npx\"\n+        assert srv[\"args\"] == [\"@mcp/github\"]\n+\n+    def test_add_connection_failure_save_disabled(\n+        self, tmp_path, capsys, monkeypatch\n+    ):\n+        \"\"\"Failed connection → option to save as disabled.\"\"\"\n+\n+        def mock_probe_fail(name, config, **kw):\n+            raise ConnectionError(\"Connection refused\")\n+\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config._probe_single_server\", mock_probe_fail\n+        )\n+        inputs = iter([\"n\", \"y\"])  # no auth, yes save disabled\n+        monkeypatch.setattr(\"builtins.input\", lambda _: next(inputs))\n+\n+        from hermes_cli.mcp_config import cmd_mcp_add\n+\n+        cmd_mcp_add(_make_args(name=\"broken\", url=\"https://bad.host/mcp\"))\n+        out = capsys.readouterr().out\n+        assert \"disabled\" in out\n+\n+        from hermes_cli.config import load_config\n+\n+        config = load_config()\n+        assert config[\"mcp_servers\"][\"broken\"][\"enabled\"] is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: cmd_mcp_test\n+# ---------------------------------------------------------------------------\n+\n+class TestMcpTest:\n+    def test_test_not_found(self, tmp_path, capsys):\n+        _seed_config(tmp_path, {})\n+        from hermes_cli.mcp_config import cmd_mcp_test\n+\n+        cmd_mcp_test(_make_args(name=\"ghost\"))\n+        out = capsys.readouterr().out\n+        assert \"not found\" in out\n+\n+    def test_test_success(self, tmp_path, capsys, monkeypatch):\n+        _seed_config(tmp_path, {\n+            \"ink\": {\"url\": \"https://mcp.ml.ink/mcp\"},\n+        })\n+\n+        def mock_probe(name, config, **kw):\n+            return [(\"create_service\", \"Deploy\"), (\"list_services\", \"List all\")]\n+\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config._probe_single_server\", mock_probe\n+        )\n+        from hermes_cli.mcp_config import cmd_mcp_test\n+\n+        cmd_mcp_test(_make_args(name=\"ink\"))\n+        out = capsys.readouterr().out\n+        assert \"Connected\" in out\n+        assert \"Tools discovered: 2\" in out\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: env var interpolation\n+# ---------------------------------------------------------------------------\n+\n+class TestEnvVarInterpolation:\n+    def test_interpolate_simple(self, monkeypatch):\n+        monkeypatch.setenv(\"MY_KEY\", \"secret123\")\n+        from tools.mcp_tool import _interpolate_env_vars\n+\n+        result = _interpolate_env_vars(\"Bearer ${MY_KEY}\")\n+        assert result == \"Bearer secret123\"\n+\n+    def test_interpolate_missing_var(self, monkeypatch):\n+        monkeypatch.delenv(\"MISSING_VAR\", raising=False)\n+        from tools.mcp_tool import _interpolate_env_vars\n+\n+        result = _interpolate_env_vars(\"Bearer ${MISSING_VAR}\")\n+        assert result == \"Bearer ${MISSING_VAR}\"\n+\n+    def test_interpolate_nested_dict(self, monkeypatch):\n+        monkeypatch.setenv(\"API_KEY\", \"abc\")\n+        from tools.mcp_tool import _interpolate_env_vars\n+\n+        result = _interpolate_env_vars({\n+            \"url\": \"https://example.com\",\n+            \"headers\": {\"Authorization\": \"Bearer ${API_KEY}\"},\n+        })\n+        assert result[\"headers\"][\"Authorization\"] == \"Bearer abc\"\n+        assert result[\"url\"] == \"https://example.com\"\n+\n+    def test_interpolate_list(self, monkeypatch):\n+        monkeypatch.setenv(\"ARG1\", \"hello\")\n+        from tools.mcp_tool import _interpolate_env_vars\n+\n+        result = _interpolate_env_vars([\"${ARG1}\", \"static\"])\n+        assert result == [\"hello\", \"static\"]\n+\n+    def test_interpolate_non_string(self):\n+        from tools.mcp_tool import _interpolate_env_vars\n+\n+        assert _interpolate_env_vars(42) == 42\n+        assert _interpolate_env_vars(True) is True\n+        assert _interpolate_env_vars(None) is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: config helpers\n+# ---------------------------------------------------------------------------\n+\n+class TestConfigHelpers:\n+    def test_save_and_load_mcp_server(self, tmp_path):\n+        from hermes_cli.mcp_config import _save_mcp_server, _get_mcp_servers\n+\n+        _save_mcp_server(\"mysvr\", {\"url\": \"https://example.com/mcp\"})\n+        servers = _get_mcp_servers()\n+        assert \"mysvr\" in servers\n+        assert servers[\"mysvr\"][\"url\"] == \"https://example.com/mcp\"\n+\n+    def test_remove_mcp_server(self, tmp_path):\n+        from hermes_cli.mcp_config import (\n+            _save_mcp_server,\n+            _remove_mcp_server,\n+            _get_mcp_servers,\n+        )\n+\n+        _save_mcp_server(\"s1\", {\"command\": \"test\"})\n+        _save_mcp_server(\"s2\", {\"command\": \"test2\"})\n+        result = _remove_mcp_server(\"s1\")\n+        assert result is True\n+        assert \"s1\" not in _get_mcp_servers()\n+        assert \"s2\" in _get_mcp_servers()\n+\n+    def test_remove_nonexistent(self, tmp_path):\n+        from hermes_cli.mcp_config import _remove_mcp_server\n+\n+        assert _remove_mcp_server(\"ghost\") is False\n+\n+    def test_env_key_for_server(self):\n+        from hermes_cli.mcp_config import _env_key_for_server\n+\n+        assert _env_key_for_server(\"ink\") == \"MCP_INK_API_KEY\"\n+        assert _env_key_for_server(\"my-server\") == \"MCP_MY_SERVER_API_KEY\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: dispatcher\n+# ---------------------------------------------------------------------------\n+\n+class TestDispatcher:\n+    def test_no_action_shows_list(self, tmp_path, capsys):\n+        from hermes_cli.mcp_config import mcp_command\n+\n+        _seed_config(tmp_path, {})\n+        mcp_command(_make_args(mcp_action=None))\n+        out = capsys.readouterr().out\n+        assert \"Commands:\" in out or \"No MCP servers\" in out"]},{"file":"tests/tools/test_mcp_oauth.py","hunks":["@@ -0,0 +1,152 @@\n+\"\"\"Tests for tools/mcp_oauth.py — thin OAuth adapter over MCP SDK.\"\"\"\n+\n+import json\n+import os\n+from pathlib import Path\n+from unittest.mock import patch, MagicMock, AsyncMock\n+\n+import pytest\n+\n+from tools.mcp_oauth import (\n+    HermesTokenStorage,\n+    build_oauth_auth,\n+    remove_oauth_tokens,\n+    _find_free_port,\n+    _can_open_browser,\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# HermesTokenStorage\n+# ---------------------------------------------------------------------------\n+\n+class TestHermesTokenStorage:\n+    def test_roundtrip_tokens(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        storage = HermesTokenStorage(\"test-server\")\n+\n+        import asyncio\n+\n+        # Initially empty\n+        assert asyncio.run(storage.get_tokens()) is None\n+\n+        # Save and retrieve\n+        mock_token = [redacted:secret-assignment]\n+        mock_token.model_dump.return_value = {\n+            \"access_token\": \"abc123\",\n+            \"token_type\": \"Bearer\",\n+            \"refresh_token\": \"ref456\",\n+        }\n+        asyncio.run(storage.set_tokens(mock_token))\n+\n+        # File exists with correct permissions\n+        token_path = [redacted:secret-assignment] / \"mcp-tokens\" / \"test-server.json\"\n+        assert token_path.exists()\n+        data = json.loads(token_path.read_text())\n+        assert data[\"access_token\"] == \"abc123\"\n+\n+    def test_roundtrip_client_info(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        storage = HermesTokenStorage(\"test-server\")\n+        import asyncio\n+\n+        assert asyncio.run(storage.get_client_info()) is None\n+\n+        mock_client = MagicMock()\n+        mock_client.model_dump.return_value = {\n+            \"client_id\": \"hermes-123\",\n+            \"client_secret\": \"secret\",\n+        }\n+        asyncio.run(storage.set_client_info(mock_client))\n+\n+        client_path = tmp_path / \"mcp-tokens\" / \"test-server.client.json\"\n+        assert client_path.exists()\n+\n+    def test_remove_cleans_up(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        storage = HermesTokenStorage(\"test-server\")\n+\n+        # Create files\n+        d = tmp_path / \"mcp-tokens\"\n+        d.mkdir(parents=True)\n+        (d / \"test-server.json\").write_text(\"{}\")\n+        (d / \"test-server.client.json\").write_text(\"{}\")\n+\n+        storage.remove()\n+        assert not (d / \"test-server.json\").exists()\n+        assert not (d / \"test-server.client.json\").exists()\n+\n+\n+# ---------------------------------------------------------------------------\n+# build_oauth_auth\n+# ---------------------------------------------------------------------------\n+\n+class TestBuildOAuthAuth:\n+    def test_returns_oauth_provider(self):\n+        try:\n+            from mcp.client.auth import OAuthClientProvider\n+        except ImportError:\n+            pytest.skip(\"MCP SDK auth not available\")\n+\n+        auth = build_oauth_auth(\"test\", \"https://example.com/mcp\")\n+        assert isinstance(auth, OAuthClientProvider)\n+\n+    def test_returns_none_without_sdk(self, monkeypatch):\n+        import tools.mcp_oauth as mod\n+        orig_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__\n+\n+        def _block_import(name, *args, **kwargs):\n+            if \"mcp.client.auth\" in name:\n+                raise ImportError(\"blocked\")\n+            return orig_import(name, *args, **kwargs)\n+\n+        with patch(\"builtins.__import__\", side_effect=_block_import):\n+            result = build_oauth_auth(\"test\", \"https://example.com\")\n+        # May or may not be None depending on import caching, but shouldn't crash\n+        assert result is None or result is not None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Utility functions\n+# ---------------------------------------------------------------------------\n+\n+class TestUtilities:\n+    def test_find_free_port_returns_int(self):\n+        port = _find_free_port()\n+        assert isinstance(port, int)\n+        assert 1024 <= port <= 65535\n+\n+    def test_can_open_browser_false_in_ssh(self, monkeypatch):\n+        monkeypatch.setenv(\"SSH_CLIENT\", \"1.2.3.4 1234 22\")\n+        assert _can_open_browser() is False\n+\n+    def test_can_open_browser_false_without_display(self, monkeypatch):\n+        monkeypatch.delenv(\"SSH_CLIENT\", raising=False)\n+        monkeypatch.delenv(\"SSH_TTY\", raising=False)\n+        monkeypatch.delenv(\"DISPLAY\", raising=False)\n+        # Mock os.name and uname for non-macOS, non-Windows\n+        monkeypatch.setattr(os, \"name\", \"posix\")\n+        monkeypatch.setattr(os, \"uname\", lambda: type(\"\", (), {\"sysname\": \"Linux\"})())\n+        assert _can_open_browser() is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# remove_oauth_tokens\n+# ---------------------------------------------------------------------------\n+\n+class TestRemoveOAuthTokens:\n+    def test_removes_files(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        d = tmp_path / \"mcp-tokens\"\n+        d.mkdir()\n+        (d / \"myserver.json\").write_text(\"{}\")\n+        (d / \"myserver.client.json\").write_text(\"{}\")\n+\n+        remove_oauth_tokens(\"myserver\")\n+\n+        assert not (d / \"myserver.json\").exists()\n+        assert not (d / \"myserver.client.json\").exists()\n+\n+    def test_no_error_when_files_missing(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        remove_oauth_tokens(\"nonexistent\")  # should not raise"]},{"file":"tools/mcp_oauth.py","hunks":["@@ -0,0 +1,235 @@\n+\"\"\"Thin OAuth adapter for MCP HTTP servers.\n+\n+Wraps the MCP SDK's built-in ``OAuthClientProvider`` (which implements\n+``httpx.Auth``) with Hermes-specific token storage and browser-based\n+authorization.  The SDK handles all of the heavy lifting: PKCE generation,\n+metadata discovery, dynamic client registration, token exchange, and refresh.\n+\n+Usage in mcp_tool.py::\n+\n+    from tools.mcp_oauth import build_oauth_auth\n+    auth = build_oauth_auth(server_name, server_url)\n+    # pass ``auth`` as the httpx auth parameter\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import json\n+import logging\n+import os\n+import socket\n+import threading\n+import webbrowser\n+from http.server import BaseHTTPRequestHandler, HTTPServer\n+from pathlib import Path\n+from typing import Any\n+from urllib.parse import parse_qs, urlparse\n+\n+logger = logging.getLogger(__name__)\n+\n+_TOKEN_DIR_NAME = \"[redacted:secret-assignment]\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Token storage — persists tokens + client info to ~/.hermes/mcp-tokens/\n+# ---------------------------------------------------------------------------\n+\n+class HermesTokenStorage:\n+    \"\"\"File-backed token storage implementing the MCP SDK's TokenStorage protocol.\"\"\"\n+\n+    def __init__(self, server_name: str):\n+        self._server_name = server_name\n+\n+    def _base_dir(self) -> Path:\n+        home = Path(os.environ.get(\"HERMES_HOME\", Path.home() / \".hermes\"))\n+        d = home / _TOKEN_DIR_NAME\n+        d.mkdir(parents=True, exist_ok=True)\n+        return d\n+\n+    def _tokens_path(self) -> Path:\n+        return self._base_dir() / f\"{self._server_name}.json\"\n+\n+    def _client_path(self) -> Path:\n+        return self._base_dir() / f\"{self._server_name}.client.json\"\n+\n+    # -- TokenStorage protocol (async) --\n+\n+    async def get_tokens(self):\n+        data = self._read_json(self._tokens_path())\n+        if not data:\n+            return None\n+        try:\n+            from mcp.shared.auth import OAuthToken\n+            return OAuthToken(**data)\n+        except Exception:\n+            return None\n+\n+    async def set_tokens(self, tokens) -> None:\n+        self._write_json(self._tokens_path(), tokens.model_dump(exclude_none=True))\n+\n+    async def get_client_info(self):\n+        data = self._read_json(self._client_path())\n+        if not data:\n+            return None\n+        try:\n+            from mcp.shared.auth import OAuthClientInformationFull\n+            return OAuthClientInformationFull(**data)\n+        except Exception:\n+            return None\n+\n+    async def set_client_info(self, client_info) -> None:\n+        self._write_json(self._client_path(), client_info.model_dump(exclude_none=True))\n+\n+    # -- helpers --\n+\n+    @staticmethod\n+    def _read_json(path: Path) -> dict | None:\n+        if not path.exists():\n+            return None\n+        try:\n+            return json.loads(path.read_text(encoding=\"utf-8\"))\n+        except Exception:\n+            return None\n+\n+    @staticmethod\n+    def _write_json(path: Path, data: dict) -> None:\n+        path.write_text(json.dumps(data, indent=2), encoding=\"utf-8\")\n+        try:\n+            path.chmod(0o600)\n+        except OSError:\n+            pass\n+\n+    def remove(self) -> None:\n+        \"\"\"Delete stored tokens and client info for this server.\"\"\"\n+        for p in (self._tokens_path(), self._client_path()):\n+            try:\n+                p.unlink(missing_ok=True)\n+            except OSError:\n+                pass\n+\n+\n+# ---------------------------------------------------------------------------\n+# Browser-based callback handler\n+# ---------------------------------------------------------------------------\n+\n+def _find_free_port() -> int:\n+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n+        s.bind((\"127.0.0.1\", 0))\n+        return s.getsockname()[1]\n+\n+\n+class _CallbackHandler(BaseHTTPRequestHandler):\n+    auth_code: str | None = None\n+    state: str | None = None\n+\n+    def do_GET(self):\n+        qs = parse_qs(urlparse(self.path).query)\n+        _CallbackHandler.auth_code = (qs.get(\"code\") or [None])[0]\n+        _CallbackHandler.state = (qs.get(\"state\") or [None])[0]\n+        self.send_response(200)\n+        self.send_header(\"Content-Type\", \"text/html\")\n+        self.end_headers()\n+        self.wfile.write(b\"<html><body><h3>Authorization complete. You can close this tab.</h3></body></html>\")\n+\n+    def log_message(self, *_args: Any) -> None:\n+        pass  # suppress HTTP log noise\n+\n+\n+async def _redirect_to_browser(auth_url: str) -> None:\n+    \"\"\"Open the authorization URL in the user's browser.\"\"\"\n+    try:\n+        if _can_open_browser():\n+            webbrowser.open(auth_url)\n+            print(f\"  Opened browser for authorization...\")\n+        else:\n+            print(f\"\\n  Open this URL to authorize:\\n  {auth_url}\\n\")\n+    except Exception:\n+        print(f\"\\n  Open this URL to authorize:\\n  {auth_url}\\n\")\n+\n+\n+async def _wait_for_callback() -> tuple[str, str | None]:\n+    \"\"\"Start a local HTTP server and wait for the OAuth redirect callback.\"\"\"\n+    port = _find_free_port()\n+    server = HTTPServer((\"127.0.0.1\", port), _CallbackHandler)\n+    _CallbackHandler.auth_code = None\n+    _CallbackHandler.state = None\n+\n+    def _serve():\n+        server.timeout = 120\n+        server.handle_request()\n+\n+    thread = threading.Thread(target=_serve, daemon=True)\n+    thread.start()\n+\n+    # Wait for the callback\n+    for _ in range(1200):  # 120 seconds\n+        await asyncio.sleep(0.1)\n+        if _CallbackHandler.auth_code is not None:\n+            break\n+\n+    server.server_close()\n+    code = _CallbackHandler.auth_code or \"\"\n+    state = _CallbackHandler.state\n+    if not code:\n+        # Fallback to manual entry\n+        print(\"  Browser callback timed out. Paste the authorization code manually:\")\n+        code = input(\"  Code: \").strip()\n+    return code, state\n+\n+\n+def _can_open_browser() -> bool:\n+    if os.environ.get(\"SSH_CLIENT\") or os.environ.get(\"SSH_TTY\"):\n+        return False\n+    if not os.environ.get(\"DISPLAY\") and os.name != \"nt\" and \"darwin\" not in os.uname().sysname.lower():\n+        return False\n+    return True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Public API\n+# ---------------------------------------------------------------------------\n+\n+def build_oauth_auth(server_name: str, server_url: str):\n+    \"\"\"Build an ``httpx.Auth`` handler for the given MCP server using OAuth 2.1 PKCE.\n+\n+    Uses the MCP SDK's ``OAuthClientProvider`` which handles discovery,\n+    registration, PKCE, token exchange, and refresh automatically.\n+\n+    Returns an ``OAuthClientProvider`` instance (implements ``httpx.Auth``),\n+    or ``None`` if the MCP SDK auth module is not available.\n+    \"\"\"\n+    try:\n+        from mcp.client.auth import OAuthClientProvider\n+        from mcp.shared.auth import OAuthClientMetadata\n+    except ImportError:\n+        logger.warning(\"MCP SDK auth module not available — OAuth disabled\")\n+        return None\n+\n+    port = _find_free_port()\n+    redirect_uri = f\"http://127.0.0.1:{port}/callback\"\n+\n+    client_metadata = OAuthClientMetadata(\n+        client_name=\"Hermes Agent\",\n+        redirect_uris=[redirect_uri],\n+        grant_types=[\"authorization_code\", \"refresh_token\"],\n+        response_types=[\"code\"],\n+        scope=\"openid profile email offline_access\",\n+        token_endpoint_auth_method=\"none\",\n+    )\n+\n+    storage = HermesTokenStorage(server_name)\n+\n+    return OAuthClientProvider(\n+        server_url=server_url,\n+        client_metadata=client_metadata,\n+        storage=storage,\n+        redirect_handler=_redirect_to_browser,\n+        callback_handler=_wait_for_callback,\n+        timeout=120.0,\n+    )\n+\n+\n+def remove_oauth_tokens(server_name: str) -> None:\n+    \"\"\"Delete stored OAuth tokens and client info for a server.\"\"\"\n+    HermesTokenStorage(server_name).remove()"]},{"file":"tools/mcp_tool.py","hunks":["@@ -690,7 +690,7 @@ class MCPServerTask:\n     __slots__ = (\n         \"name\", \"session\", \"tool_timeout\",\n         \"_task\", \"_ready\", \"_shutdown_event\", \"_tools\", \"_error\", \"_config\",\n-        \"_sampling\", \"_registered_tool_names\",\n+        \"_sampling\", \"_registered_tool_names\", \"_auth_type\",\n     )\n \n     def __init__(self, name: str):","@@ -705,6 +705,7 @@ def __init__(self, name: str):\n         self._config: dict = {}\n         self._sampling: Optional[SamplingHandler] = None\n         self._registered_tool_names: list[str] = []\n+        self._auth_type: str = \"\"\n \n     def _is_http(self) -> bool:\n         \"\"\"Check if this server uses HTTP transport.\"\"\"","@@ -748,15 +749,28 @@ async def _run_http(self, config: dict):\n             )\n \n         url = config[\"url\"]\n-        headers = config.get(\"headers\")\n+        headers = dict(config.get(\"headers\") or {})\n         connect_timeout = config.get(\"connect_timeout\", _DEFAULT_CONNECT_TIMEOUT)\n \n+        # OAuth 2.1 PKCE: build httpx.Auth handler using the MCP SDK\n+        _oauth_auth = None\n+        if self._auth_type == \"oauth\":\n+            try:\n+                from tools.mcp_oauth import build_oauth_auth\n+                _oauth_auth = build_oauth_auth(self.name, url)\n+            except Exception as exc:\n+                logger.warning(\"MCP OAuth setup failed for '%s': %s\", self.name, exc)\n+\n         sampling_kwargs = self._sampling.session_kwargs() if self._sampling else {}\n-        async with streamablehttp_client(\n-            url,\n-            headers=headers,\n-            timeout=float(connect_timeout),\n-        ) as (read_stream, write_stream, _get_session_id):\n+        _http_kwargs: dict = {\n+            \"headers\": headers,\n+            \"timeout\": float(connect_timeout),\n+        }\n+        if _oauth_auth is not None:\n+            _http_kwargs[\"auth\"] = _oauth_auth\n+        async with streamablehttp_client(url, **_http_kwargs) as (\n+            read_stream, write_stream, _get_session_id,\n+        ):\n             async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:\n                 await session.initialize()\n                 self.session = session","@@ -783,6 +797,7 @@ async def run(self, config: dict):\n         \"\"\"\n         self._config = config\n         self.tool_timeout = config.get(\"timeout\", _DEFAULT_TOOL_TIMEOUT)\n+        self._auth_type = config.get(\"auth\", \"\").lower().strip()\n \n         # Set up sampling handler if enabled and SDK types are available\n         sampling_config = config.get(\"sampling\", {})","@@ -920,13 +935,30 @@ def _run_on_mcp_loop(coro, timeout: float = 30):\n # Config loading\n # ---------------------------------------------------------------------------\n \n+def _interpolate_env_vars(value):\n+    \"\"\"Recursively resolve ``${VAR}`` placeholders from ``os.environ``.\"\"\"\n+    if isinstance(value, str):\n+        import re\n+        def _replace(m):\n+            return os.environ.get(m.group(1), m.group(0))\n+        return re.sub(r\"\\$\\{([^}]+)\\}\", _replace, value)\n+    if isinstance(value, dict):\n+        return {k: _interpolate_env_vars(v) for k, v in value.items()}\n+    if isinstance(value, list):\n+        return [_interpolate_env_vars(v) for v in value]\n+    return value\n+\n+\n def _load_mcp_config() -> Dict[str, dict]:\n     \"\"\"Read ``mcp_servers`` from the Hermes config file.\n \n     Returns a dict of ``{server_name: server_config}`` or empty dict.\n     Server config can contain either ``command``/``args``/``env`` for stdio\n     transport or ``url``/``headers`` for HTTP transport, plus optional\n-    ``timeout`` and ``connect_timeout`` overrides.\n+    ``timeout``, ``connect_timeout``, and ``auth`` overrides.\n+\n+    ``${ENV_VAR}`` placeholders in string values are resolved from\n+    ``os.environ`` (which includes ``~/.hermes/.env`` loaded at startup).\n     \"\"\"\n     try:\n         from hermes_cli.config import load_config","@@ -934,7 +966,13 @@ def _load_mcp_config() -> Dict[str, dict]:\n         servers = config.get(\"mcp_servers\")\n         if not servers or not isinstance(servers, dict):\n             return {}\n-        return servers\n+        # Ensure .env vars are available for interpolation\n+        try:\n+            from hermes_cli.env_loader import load_hermes_dotenv\n+            load_hermes_dotenv()\n+        except Exception:\n+            pass\n+        return {name: _interpolate_env_vars(cfg) for name, cfg in servers.items()}\n     except Exception as exc:\n         logger.debug(\"Failed to load MCP config: %s\", exc)\n         return {}\n"]}]},{"kind":"pr","number":34787,"title":"fix(mcp): require Google Drive OAuth client credentials and real login","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"27a2c4f36ff323e5edc300496d001e28a0c35678","number":0},{"kind":"pr","value":"#34775","number":34775},{"kind":"pr","value":"#34807","number":34807}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34807,"title":"fix(mcp): stop reporting false OAuth success when no token was obtained","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/mcp_config.py","tests/hermes_cli/test_mcp_config.py","website/docs/user-guide/features/code-execution.md","website/docs/user-guide/features/mcp.md"]},{"kind":"pr","number":37598,"title":"fix(mcp): add POST probe fallback in preflight content-type check","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"81becec45","number":0},{"kind":"pr","value":"#59316","number":59316},{"kind":"pr","value":"#57958","number":57958}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":51600,"title":"fix(mcp): skip connection probe when OAuth just configured in `mcp add`","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#51535","number":51535}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":52464,"title":"fix: disable redirect following in MCP preflight content-type check","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d05cc8f4d","number":0},{"kind":"pr","value":"#52460","number":52460},{"kind":"pr","value":"#59316","number":59316},{"kind":"pr","value":"#52465","number":52465}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":52465,"title":"Fix: MCP Streamable HTTP servers fail preflight check when GET redirects","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d05cc8f4d","number":0},{"kind":"pr","value":"#52460","number":52460},{"kind":"pr","value":"#59316","number":59316},{"kind":"pr","value":"#52464","number":52464}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":53873,"title":"fix(mcp): force OAuth flow when server returns 200 for initialize without auth (#53870)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":58002,"title":"fix(mcp): add POST probe fallback in preflight content-type check (#57958)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#37598","number":37598},{"kind":"pr","value":"#59316","number":59316},{"kind":"pr","value":"#57958","number":57958}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":59316,"title":"fix(mcp): POST probe fallback in preflight + skip_preflight opt-out (salvage #37598, #55203)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_mcp_preflight_content_type.py","tools/mcp_tool.py","website/docs/reference/mcp-config-reference.md"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -1208,6 +1208,8 @@\n     \"[redacted:email]\": \"r266-tech\",\n     \"[redacted:email]\": \"r266-tech\",  # PR #55780 salvage (dead-target not_found blast radius)\n     \"[redacted:email]\": \"BlackishGreen33\",\n+    \"[redacted:email]\": \"gnodet\",  # PR #37598 salvage (MCP preflight POST probe fallback)\n+    \"[redacted:email]\": \"kaishi00\",  # PR #55203 salvage (skip_preflight opt-out)\n     \"[redacted:email]\": \"sberan\",  # PR #54494 salvage (--connect-timeout flag on hermes mcp add)\n     \"[redacted:email]\": \"labsobsidian\",  # PR #56699 salvage (MCP OAuth login connect_timeout floor)\n     \"[redacted:email]\": \"SaulJWu\","]},{"file":"tests/tools/test_mcp_preflight_content_type.py","hunks":["@@ -5,8 +5,8 @@\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+content-type allow-list, HEAD->GET fallback, POST probe fallback, and\n+best-effort pass-through are all covered as shipped.\n \n OAuth note\n ----------","@@ -56,12 +56,19 @@ def _serve(handler_cls):\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+             body: bytes = b\"<html>x</html>\", head_status=None, record=None,\n+             post_content_type: \"str | None\" = None,\n+             post_body: bytes = b\"\",\n+             post_status: \"int | None\" = 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+    ``post_content_type`` / ``post_body`` / ``post_status`` let POST return a\n+    different response than HEAD/GET (to exercise the POST probe fallback for\n+    servers that serve HTML on GET but speak MCP via POST).\n     \"\"\"\n \n     class _H(http.server.BaseHTTPRequestHandler):","@@ -85,6 +92,18 @@ def do_GET(self):\n                 record.append(\"GET\")\n             self._write(status, content_type, body)\n \n+        def do_POST(self):\n+            if record is not None:\n+                record.append(\"POST\")\n+            # Read and discard request body to avoid broken pipe.\n+            length = int(self.headers.get(\"Content-Length\", 0))\n+            if length:\n+                self.rfile.read(length)\n+            sc = post_status if post_status is not None else status\n+            ct = post_content_type if post_content_type is not None else content_type\n+            pb = post_body if post_body else body\n+            self._write(sc, ct, pb)\n+\n         def log_message(self, format, *args):  # noqa: A002\n             pass\n ","@@ -190,6 +209,7 @@ async def __aenter__(self):\n # ---------------------------------------------------------------------------\n \n def test_head_405_falls_back_to_get_and_rejects_html():\n+    \"\"\"HEAD→405, GET→html, POST probe also returns html → reject.\"\"\"\n     task = _make_task(\"fallback_srv\")\n     record: list[str] = []\n     with _serve(_handler(","@@ -198,7 +218,8 @@ def test_head_405_falls_back_to_get_and_rejects_html():\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+    # HEAD → 405, falls back to GET (html), then POST probe (also html) → reject.\n+    assert record == [\"HEAD\", \"GET\", \"POST\"]\n \n \n def test_head_501_falls_back_to_get_and_passes_json():","@@ -284,6 +305,41 @@ async def _fake_run_http(self, config):\n     )\n \n \n+def test_run_skips_preflight_when_skip_preflight_set(monkeypatch):\n+    \"\"\"``skip_preflight: true`` in server config bypasses the probe entirely.\n+\n+    Escape hatch for valid Streamable HTTP servers whose HEAD/GET answers a\n+    non-MCP content type (and whose POST probe still can't be validated, e.g.\n+    non-OAuth auth schemes the probe headers don't satisfy).\n+    \"\"\"\n+    import tools.mcp_tool as _mcp\n+\n+    preflight_calls: list[str] = []\n+\n+    async def _inner():\n+        async def _fake_preflight(self, url, **kwargs):\n+            preflight_calls.append(url)\n+\n+        async def _fake_run_http(self, config):\n+            raise asyncio.CancelledError()\n+\n+        monkeypatch.setattr(_mcp, \"_validate_remote_mcp_url\", lambda n, u: None)\n+        monkeypatch.setattr(_mcp.MCPServerTask, \"_preflight_content_type\", _fake_preflight)\n+        monkeypatch.setattr(_mcp.MCPServerTask, \"_run_http\", _fake_run_http)\n+\n+        task = _mcp.MCPServerTask(\"skip-preflight-test\")\n+        with pytest.raises(asyncio.CancelledError):\n+            await task.run({\n+                \"url\": \"https://mcp.example.com/mcp\",\n+                \"skip_preflight\": True,\n+            })\n+\n+    asyncio.run(_inner())\n+    assert preflight_calls == [], (\n+        \"_preflight_content_type must not be called when skip_preflight is set\"\n+    )\n+\n+\n def test_ssl_verify_and_cert_forwarded(monkeypatch):\n     captured: dict = {}\n ","@@ -313,3 +369,78 @@ async def head(self, url, headers=None):\n     assert captured.get(\"verify\") is False\n     assert captured.get(\"cert\") == \"/path/to/cert.pem\"\n     assert captured.get(\"follow_redirects\") is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# POST probe fallback for POST-only MCP servers\n+# ---------------------------------------------------------------------------\n+\n+def test_post_probe_rescues_html_head_with_json_post():\n+    \"\"\"HEAD returns text/html but POST returns application/json → pass.\"\"\"\n+    task = _make_task()\n+    record: list[str] = []\n+    with _serve(_handler(\n+        status=200, content_type=\"text/html\",\n+        post_content_type=\"application/json; charset=utf-8\",\n+        post_body=b'{\"jsonrpc\":\"2.0\",\"id\":\"_probe\",\"result\":{}}',\n+        record=record,\n+    )) as base:\n+        # Must not raise — the POST probe should rescue this.\n+        asyncio.run(task._preflight_content_type(f\"{base}/mcp\", timeout=5.0))\n+    assert \"HEAD\" in record\n+    assert \"POST\" in record\n+\n+\n+def test_post_probe_rescues_html_head_with_event_stream_post():\n+    \"\"\"HEAD returns text/html but POST returns text/event-stream → pass.\"\"\"\n+    task = _make_task()\n+    with _serve(_handler(\n+        status=200, content_type=\"text/html\",\n+        post_content_type=\"text/event-stream\",\n+        post_body=b\"data: {}\\n\\n\",\n+    )) as base:\n+        asyncio.run(task._preflight_content_type(f\"{base}/mcp\", timeout=5.0))\n+\n+\n+def test_post_probe_still_rejects_when_post_also_returns_html():\n+    \"\"\"HEAD and POST both return text/html → reject.\"\"\"\n+    task = _make_task(\"both_html\")\n+    with _serve(_handler(\n+        status=200, content_type=\"text/html\",\n+        post_content_type=\"text/html\",\n+        post_body=b\"<html>nope</html>\",\n+    )) as base:\n+        with pytest.raises(NonMcpEndpointError):\n+            asyncio.run(task._preflight_content_type(f\"{base}/\", timeout=5.0))\n+\n+\n+def test_post_probe_still_rejects_when_post_returns_non_2xx():\n+    \"\"\"HEAD returns HTML, POST returns 401 with JSON → reject.\n+\n+    A non-2xx POST does not prove MCP capability; the original HEAD/GET\n+    response is used and should still trigger rejection.\n+    \"\"\"\n+    task = _make_task(\"post_401\")\n+    with _serve(_handler(\n+        status=200, content_type=\"text/html\",\n+        post_content_type=\"application/json\",\n+        post_body=b'{\"error\":\"unauthorized\"}',\n+        post_status=401,\n+    )) as base:\n+        with pytest.raises(NonMcpEndpointError):\n+            asyncio.run(task._preflight_content_type(f\"{base}/\", timeout=5.0))\n+\n+\n+def test_post_probe_not_attempted_for_valid_head():\n+    \"\"\"When HEAD already returns application/json, no POST probe is needed.\"\"\"\n+    task = _make_task()\n+    record: list[str] = []\n+    with _serve(_handler(\n+        status=200, content_type=\"application/json\", body=b\"{}\",\n+        post_content_type=\"application/json\",\n+        post_body=b'{}',\n+        record=record,\n+    )) as base:\n+        asyncio.run(task._preflight_content_type(f\"{base}/mcp\", timeout=5.0))\n+    assert record == [\"HEAD\"]\n+    assert \"POST\" not in record"]},{"file":"tools/mcp_tool.py","hunks":["@@ -34,6 +34,10 @@\n         headers:\n           Authorization: \"Bearer sk-...\"\n         timeout: 180\n+        skip_preflight: true  # bypass the content-type probe for a valid\n+                              # Streamable HTTP endpoint that answers HEAD/GET\n+                              # with a non-MCP content type but serves real\n+                              # MCP over POST. Default: false.\n       searxng:\n         url: \"http://localhost:8000/sse\"\n         transport: sse       # use SSE transport instead of Streamable HTTP","@@ -2067,11 +2071,17 @@ async def _preflight_content_type(\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+        (``application/json`` / ``text/event-stream``).  When HEAD/GET returns\n+        a non-MCP content type (e.g. ``text/html``), a lightweight JSON-RPC\n+        ``initialize`` POST is attempted before giving up — some servers\n+        (e.g. DocuSeal) serve a web UI on GET but speak Streamable HTTP only\n+        via POST.\n+\n+        A missing or empty content type, non-2xx status, or any\n+        network/transport error passes through silently — the probe is\n+        strictly best-effort, and the real handshake remains the source of\n+        truth for everything except the unambiguous \"this is a web page,\n+        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","@@ -2099,6 +2109,47 @@ async def _preflight_content_type(\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+\n+                # Some MCP servers (e.g. DocuSeal) serve their web UI on\n+                # HEAD/GET but speak Streamable HTTP only via POST.  Before\n+                # rejecting the endpoint, try a lightweight JSON-RPC POST\n+                # probe so we don't false-positive on POST-only servers.\n+                ct = (\n+                    resp.headers.get(\"content-type\", \"\")\n+                    .split(\";\")[0]\n+                    .strip()\n+                    .lower()\n+                )\n+                if (\n+                    ct\n+                    and ct not in self._MCP_CONTENT_TYPES\n+                    and 200 <= resp.status_code < 300\n+                ):\n+                    post_resp = await client.post(\n+                        url,\n+                        headers={\n+                            **probe_headers,\n+                            \"Content-Type\": \"application/json\",\n+                            \"Accept\": \"application/json, text/event-stream\",\n+                        },\n+                        content=(\n+                            '{\"jsonrpc\":\"2.0\",\"id\":\"_probe\",'\n+                            '\"method\":\"initialize\",'\n+                            '\"params\":{\"protocolVersion\":\"2025-03-26\",'\n+                            '\"capabilities\":{},'\n+                            '\"clientInfo\":{\"name\":\"hermes-probe\",'\n+                            '\"version\":\"0.1\"}}}'\n+                        ),\n+                    )\n+                    if 200 <= post_resp.status_code < 300:\n+                        post_ct = (\n+                            post_resp.headers.get(\"content-type\", \"\")\n+                            .split(\";\")[0]\n+                            .strip()\n+                            .lower()\n+                        )\n+                        if post_ct in self._MCP_CONTENT_TYPES:\n+                            resp = post_resp\n         except _httpx.HTTPError:\n             return  # DNS/connect/timeout/transport error — let the SDK try.\n ","@@ -2442,7 +2493,7 @@ async def run(self, config: dict):\n             # re-probing is a redundant round-trip. Also skip for OAuth servers:\n             # without a cached token the endpoint returns HTML or 401, which\n             # would incorrectly block the OAuth flow before it can run.\n-            if config.get(\"transport\") != \"sse\" and not self._ready.is_set() and self._auth_type != \"oauth\":\n+            if config.get(\"transport\") != \"sse\" and not config.get(\"skip_preflight\") and not self._ready.is_set() and self._auth_type != \"oauth\":\n                 try:\n                     _probe_headers = dict(config.get(\"headers\") or {})\n                     await self._preflight_content_type("]},{"file":"website/docs/reference/mcp-config-reference.md","hunks":["@@ -57,6 +57,7 @@ mcp_servers:\n | `timeout` | number | both | Tool call timeout in seconds (default: `300`) |\n | `connect_timeout` | number | both | Initial connection timeout in seconds (default: `60`) |\n | `supports_parallel_tool_calls` | bool | both | Allow tools from this server to run concurrently |\n+| `skip_preflight` | bool | HTTP | Bypass the fail-fast content-type probe for valid Streamable HTTP endpoints whose HEAD/GET answers a non-MCP content type (default: `false`) |\n | `tools` | mapping | both | Filtering and utility-tool policy |\n | `auth` | string | HTTP | Authentication method. Set to `oauth` to enable OAuth 2.1 with PKCE |\n | `sampling` | mapping | both | Server-initiated LLM request policy (see MCP guide) |\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}