diff --git a/mcp_nixos/server.py b/mcp_nixos/server.py index 3805a72..cf3ce62 100644 --- a/mcp_nixos/server.py +++ b/mcp_nixos/server.py @@ -249,7 +249,9 @@ def parse_html_options(url: str, query: str = "", prefix: str = "", limit: int = try: resp = requests.get(url, timeout=30) # Increase timeout for large docs resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") + # Use resp.content to let BeautifulSoup handle encoding detection + # This prevents encoding errors like "unknown encoding: windows-1252" + soup = BeautifulSoup(resp.content, "html.parser") options = [] # Get all dt elements diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 37e6389..2b72101 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -108,7 +108,7 @@ def test_parse_html_options_large_document(self, mock_get): mock_resp = Mock() mock_resp.raise_for_status = Mock() - mock_resp.text = large_html + mock_resp.content = large_html.encode("utf-8") mock_get.return_value = mock_resp # Should respect limit @@ -135,7 +135,7 @@ def test_parse_html_options_malformed_html(self, mock_get): mock_resp = Mock() mock_resp.raise_for_status = Mock() - mock_resp.text = malformed_html + mock_resp.content = malformed_html.encode("utf-8") mock_get.return_value = mock_resp options = parse_html_options("http://test.com") @@ -160,7 +160,7 @@ def test_parse_html_options_special_characters(self, mock_get): mock_resp = Mock() mock_resp.raise_for_status = Mock() - mock_resp.text = html_with_entities + mock_resp.content = html_with_entities.encode("utf-8") mock_get.return_value = mock_resp options = parse_html_options("http://test.com") diff --git a/tests/test_evals.py b/tests/test_evals.py index 047061a..823c7df 100644 --- a/tests/test_evals.py +++ b/tests/test_evals.py @@ -124,7 +124,7 @@ async def test_complete_firefox_installation_flow(self, mock_get, mock_post): # Step 3: Search Home Manager options hm_resp = Mock() - hm_resp.text = """ + hm_resp.content = b"""
programs.firefox.enable
diff --git a/tests/test_flakes.py b/tests/test_flakes.py index d7fdb28..5d03c21 100644 --- a/tests/test_flakes.py +++ b/tests/test_flakes.py @@ -249,7 +249,7 @@ async def test_flake_search_error_handling(self, mock_post): """Test flake search error handling.""" mock_response = MagicMock() mock_response.status_code = 500 - mock_response.text = "Internal Server Error" + mock_response.content = b"Internal Server Error" # Create an HTTPError with a response attribute http_error = requests.HTTPError("500 Server Error") @@ -314,7 +314,7 @@ async def test_home_manager_stats_with_data(self, mock_get): """ mock_get.return_value.status_code = 200 - mock_get.return_value.text = mock_html + mock_get.return_value.content = mock_html.encode("utf-8") result = await home_manager_stats() @@ -329,7 +329,7 @@ async def test_home_manager_stats_with_data(self, mock_get): async def test_home_manager_stats_error_handling(self, mock_get): """Test home_manager_stats error handling.""" mock_get.return_value.status_code = 404 - mock_get.return_value.text = "Not Found" + mock_get.return_value.content = b"Not Found" result = await home_manager_stats() @@ -359,7 +359,7 @@ async def test_darwin_stats_with_data(self, mock_get): """ mock_get.return_value.status_code = 200 - mock_get.return_value.text = mock_html + mock_get.return_value.content = mock_html.encode("utf-8") result = await darwin_stats() @@ -374,7 +374,7 @@ async def test_darwin_stats_with_data(self, mock_get): async def test_darwin_stats_error_handling(self, mock_get): """Test darwin_stats error handling.""" mock_get.return_value.status_code = 500 - mock_get.return_value.text = "Server Error" + mock_get.return_value.content = b"Server Error" result = await darwin_stats() @@ -402,7 +402,7 @@ async def test_stats_with_complex_categories(self, mock_get): """ mock_get.return_value.status_code = 200 - mock_get.return_value.text = mock_html + mock_get.return_value.content = mock_html.encode("utf-8") result = await home_manager_stats() @@ -416,7 +416,7 @@ async def test_stats_with_complex_categories(self, mock_get): async def test_stats_with_empty_html(self, mock_get): """Test stats functions with empty HTML.""" mock_get.return_value.status_code = 200 - mock_get.return_value.text = "" + mock_get.return_value.content = b"" result = await home_manager_stats() @@ -546,7 +546,7 @@ async def test_combined_workflow_stats_and_search(self, mock_post, mock_get): """ mock_get.return_value.status_code = 200 - mock_get.return_value.text = stats_html + mock_get.return_value.content = stats_html.encode("utf-8") stats_result = await home_manager_stats() diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 4047e11..23c8424 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -148,7 +148,7 @@ async def test_parse_html_options_type_extraction(self, mock_get): """Test that type information is not properly extracted from HTML.""" # Mock HTML response with proper structure mock_response = MagicMock() - mock_response.text = """ + mock_response.content = """.encode("utf-8")
programs.git.enable
diff --git a/tests/test_plain_text_output.py b/tests/test_plain_text_output.py index fab9059..a4c8288 100644 --- a/tests/test_plain_text_output.py +++ b/tests/test_plain_text_output.py @@ -143,7 +143,7 @@ async def test_home_manager_search_plain_text(self, mock_get): """Test home_manager_search returns plain text.""" # Mock HTML response mock_response = Mock() - mock_response.text = """ + mock_response.content = """.encode("utf-8")
programs.git.enable
@@ -168,7 +168,7 @@ async def test_home_manager_info_plain_text(self, mock_get): """Test home_manager_info returns plain text.""" # Mock HTML response mock_response = Mock() - mock_response.text = """ + mock_response.content = """.encode("utf-8")
programs.git.enable
@@ -215,7 +215,7 @@ async def test_home_manager_list_options_plain_text(self, mock_get): """Test home_manager_list_options returns plain text.""" # Mock HTML response mock_response = Mock() - mock_response.text = """ + mock_response.content = """.encode("utf-8")
programs.git.enable

Enable git

@@ -238,7 +238,7 @@ async def test_darwin_search_plain_text(self, mock_get): """Test darwin_search returns plain text.""" # Mock HTML response mock_response = Mock() - mock_response.text = """ + mock_response.content = """.encode("utf-8")
system.defaults.dock.autohide
@@ -263,7 +263,7 @@ async def test_no_results_plain_text(self, mock_get): """Test empty results return appropriate plain text.""" # Mock empty HTML response mock_response = Mock() - mock_response.text = "" + mock_response.content = b"" mock_response.raise_for_status = Mock() mock_get.return_value = mock_response diff --git a/tests/test_server.py b/tests/test_server.py index 76c91f8..f2a467a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -129,7 +129,7 @@ def test_es_query_missing_hits(self, mock_post): def test_parse_html_options_success(self, mock_get): """Test successful HTML parsing.""" mock_resp = Mock() - mock_resp.text = """ + html_content = """
programs.git.enable
@@ -143,6 +143,7 @@ def test_parse_html_options_success(self, mock_get):
""" + mock_resp.content = html_content.encode("utf-8") mock_resp.raise_for_status = Mock() mock_get.return_value = mock_resp @@ -156,7 +157,7 @@ def test_parse_html_options_success(self, mock_get): def test_parse_html_options_with_query(self, mock_get): """Test HTML parsing with query filter.""" mock_resp = Mock() - mock_resp.text = """ + html_content = """
programs.git.enable

Enable git

@@ -164,6 +165,7 @@ def test_parse_html_options_with_query(self, mock_get):

Enable vim

""" + mock_resp.content = html_content.encode("utf-8") mock_resp.raise_for_status = Mock() mock_get.return_value = mock_resp @@ -175,7 +177,7 @@ def test_parse_html_options_with_query(self, mock_get): def test_parse_html_options_with_prefix(self, mock_get): """Test HTML parsing with prefix filter.""" mock_resp = Mock() - mock_resp.text = """ + html_content = """
programs.git.enable

Enable git

@@ -183,6 +185,7 @@ def test_parse_html_options_with_prefix(self, mock_get):

Enable nginx

""" + mock_resp.content = html_content.encode("utf-8") mock_resp.raise_for_status = Mock() mock_get.return_value = mock_resp @@ -194,7 +197,7 @@ def test_parse_html_options_with_prefix(self, mock_get): def test_parse_html_options_empty_response(self, mock_get): """Test HTML parsing with empty response.""" mock_resp = Mock() - mock_resp.text = "" + mock_resp.content = b"" mock_resp.raise_for_status = Mock() mock_get.return_value = mock_resp @@ -217,13 +220,92 @@ def test_parse_html_options_limit(self, mock_get): options_html = "" for i in range(10): options_html += f"
option.{i}

desc{i}

" - mock_resp.text = f"{options_html}" + mock_resp.content = f"{options_html}".encode() mock_resp.raise_for_status = Mock() mock_get.return_value = mock_resp result = parse_html_options("http://test.com", limit=5) assert len(result) == 5 + @patch("mcp_nixos.server.requests.get") + def test_parse_html_options_windows_1252_encoding(self, mock_get): + """Test HTML parsing with windows-1252 encoding.""" + # Create HTML content with special characters + html_content = """ + + +
programs.git.userName
+
+

Git user name with special chars: café

+ Type: string +
+ + """ + + mock_resp = Mock() + # Simulate windows-1252 encoded content + mock_resp.content = html_content.encode("windows-1252") + mock_resp.encoding = "windows-1252" + mock_resp.raise_for_status = Mock() + mock_get.return_value = mock_resp + + # Should not raise encoding errors + result = parse_html_options("http://test.com") + assert len(result) == 1 + assert result[0]["name"] == "programs.git.userName" + assert "café" in result[0]["description"] + + @patch("mcp_nixos.server.requests.get") + def test_parse_html_options_utf8_with_bom(self, mock_get): + """Test HTML parsing with UTF-8 BOM.""" + html_content = """ + +
programs.neovim.enable
+
+

Enable Neovim with unicode: 你好

+ Type: boolean +
+ + """ + + mock_resp = Mock() + # Add UTF-8 BOM at the beginning + mock_resp.content = b"\xef\xbb\xbf" + html_content.encode("utf-8") + mock_resp.encoding = "utf-8-sig" + mock_resp.raise_for_status = Mock() + mock_get.return_value = mock_resp + + result = parse_html_options("http://test.com") + assert len(result) == 1 + assert result[0]["name"] == "programs.neovim.enable" + assert "你好" in result[0]["description"] + + @patch("mcp_nixos.server.requests.get") + def test_parse_html_options_iso_8859_1_encoding(self, mock_get): + """Test HTML parsing with ISO-8859-1 encoding.""" + html_content = """ + + +
services.nginx.virtualHosts
+
+

Nginx config with special: naïve résumé

+
+ + """ + + mock_resp = Mock() + # Simulate ISO-8859-1 encoded content + mock_resp.content = html_content.encode("iso-8859-1") + mock_resp.encoding = "iso-8859-1" + mock_resp.raise_for_status = Mock() + mock_get.return_value = mock_resp + + result = parse_html_options("http://test.com") + assert len(result) == 1 + assert result[0]["name"] == "services.nginx.virtualHosts" + assert "naïve" in result[0]["description"] + assert "résumé" in result[0]["description"] + class TestNixOSTools: """Test all NixOS tools.""" @@ -516,8 +598,10 @@ async def test_home_manager_stats(self, mock_get): """ - mock_get.return_value.status_code = 200 - mock_get.return_value.text = mock_html + mock_resp = Mock() + mock_resp.content = mock_html.encode("utf-8") + mock_resp.raise_for_status = Mock() + mock_get.return_value = mock_resp result = await home_manager_stats() assert "Home Manager Statistics:" in result @@ -604,8 +688,10 @@ async def test_darwin_stats(self, mock_get): """ - mock_get.return_value.status_code = 200 - mock_get.return_value.text = mock_html + mock_resp = Mock() + mock_resp.content = mock_html.encode("utf-8") + mock_resp.raise_for_status = Mock() + mock_get.return_value = mock_resp result = await darwin_stats() assert "nix-darwin Statistics:" in result @@ -664,7 +750,7 @@ async def test_special_characters_in_query(self, mock_query): def test_malformed_html_response(self, mock_get): """Test parsing malformed HTML.""" mock_resp = Mock() - mock_resp.text = "
broken" # Malformed HTML + mock_resp.content = b"
broken" # Malformed HTML mock_resp.raise_for_status = Mock() mock_get.return_value = mock_resp diff --git a/uv.lock b/uv.lock index d19292b..89c4c50 100644 --- a/uv.lock +++ b/uv.lock @@ -731,7 +731,7 @@ wheels = [ [[package]] name = "mcp-nixos" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },