"""Tests for MCP config hidden-whitespace warnings. Inspired by Claude Code v2.1.219: warn when MCP config values carry hidden leading/trailing whitespace (pasted tokens with trailing newlines, URLs with leading spaces), which otherwise surfaces as opaque auth/connect failures. """ import logging import pytest from tools import mcp_tool from tools.mcp_tool import _warn_hidden_whitespace @pytest.fixture(autouse=True) def _reset_dedupe(): mcp_tool._whitespace_warned.clear() yield mcp_tool._whitespace_warned.clear() def test_clean_config_no_warnings(caplog): config = { "url": "https://example.com/mcp", "headers": {"Authorization": "Bearer abc123"}, "args": ["--flag", "value"], } with caplog.at_level(logging.WARNING, logger="tools.mcp_tool"): flagged = _warn_hidden_whitespace("clean", config) assert flagged == [] assert not [r for r in caplog.records if "hidden" in r.getMessage()] def test_trailing_newline_in_header_flagged(caplog): config = {"url": "https://example.com/mcp", "headers": {"Authorization": "Bearer abc123\n"}} with caplog.at_level(logging.WARNING, logger="tools.mcp_tool"): flagged = _warn_hidden_whitespace("srv", config) assert flagged == ["headers.Authorization"] messages = [r.getMessage() for r in caplog.records] assert any("srv" in m and "headers.Authorization" in m for m in messages) # The secret value itself must never appear in the log. assert not any("abc123" in m for m in messages) def test_leading_space_in_url_flagged(): flagged = _warn_hidden_whitespace("srv", {"url": " https://example.com"}) assert flagged == ["url"] def test_whitespace_in_list_item_flagged_with_index(): flagged = _warn_hidden_whitespace( "srv", {"command": "npx", "args": ["-y", "some-pkg "]} ) assert flagged == ["args[1]"] def test_nested_env_dict_flagged(): flagged = _warn_hidden_whitespace( "srv", {"command": "npx", "env": {"API_KEY": "secret\t"}} ) assert flagged == ["env.API_KEY"] def test_multiple_flags_all_reported(): flagged = _warn_hidden_whitespace( "srv", {"url": "https://x.com ", "headers": {"X-Key": " k"}} ) assert set(flagged) == {"url", "headers.X-Key"} def test_non_string_values_ignored(): flagged = _warn_hidden_whitespace( "srv", {"timeout": 30, "enabled": True, "retries": None} ) assert flagged == [] def test_values_never_mutated(): config = {"headers": {"Authorization": "Bearer tok\n"}} _warn_hidden_whitespace("srv", config) assert config["headers"]["Authorization"] == "Bearer tok\n" def test_warning_deduped_per_process(caplog): config = {"url": "https://x.com "} with caplog.at_level(logging.WARNING, logger="tools.mcp_tool"): first = _warn_hidden_whitespace("srv", config) second = _warn_hidden_whitespace("srv", config) # Both calls still report the flagged path (return value is for callers)... assert first == second == ["url"] # ...but only one warning record is emitted. warn_records = [r for r in caplog.records if "hidden" in r.getMessage()] assert len(warn_records) == 1 def test_distinct_servers_warned_separately(caplog): with caplog.at_level(logging.WARNING, logger="tools.mcp_tool"): _warn_hidden_whitespace("a", {"url": "https://x.com "}) _warn_hidden_whitespace("b", {"url": "https://x.com "}) warn_records = [r for r in caplog.records if "hidden" in r.getMessage()] assert len(warn_records) == 2 def test_load_mcp_config_emits_warning(tmp_path, monkeypatch, caplog): """E2E through _load_mcp_config with a real config load path.""" from unittest.mock import patch as mock_patch servers = { "pasted": { "url": "https://example.com/mcp", "headers": {"Authorization": "Bearer tok\n"}, } } with mock_patch("hermes_cli.config.load_config", return_value={"mcp_servers": servers}), \ caplog.at_level(logging.WARNING, logger="tools.mcp_tool"): result = mcp_tool._load_mcp_config() assert "pasted" in result # Value passes through unmutated. assert result["pasted"]["headers"]["Authorization"] == "Bearer tok\n" messages = [r.getMessage() for r in caplog.records] assert any("headers.Authorization" in m for m in messages)