This is page 2 of 3. Use http://codebase.md/utensils/mcp-nixos?page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ ├── mcp-server-architect.md
│ │ ├── nix-expert.md
│ │ └── python-expert.md
│ ├── commands
│ │ └── release.md
│ └── settings.json
├── .dockerignore
├── .envrc
├── .github
│ └── workflows
│ ├── ci.yml
│ ├── claude-code-review.yml
│ ├── claude.yml
│ ├── deploy-flakehub.yml
│ ├── deploy-website.yml
│ └── publish.yml
├── .gitignore
├── .mcp.json
├── .pre-commit-config.yaml
├── CLAUDE.md
├── Dockerfile
├── flake.lock
├── flake.nix
├── LICENSE
├── MANIFEST.in
├── mcp_nixos
│ ├── __init__.py
│ └── server.py
├── pyproject.toml
├── pytest.ini
├── README.md
├── RELEASE_NOTES.md
├── RELEASE_WORKFLOW.md
├── smithery.yaml
├── tests
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_channels.py
│ ├── test_edge_cases.py
│ ├── test_evals.py
│ ├── test_flakes.py
│ ├── test_integration.py
│ ├── test_main.py
│ ├── test_mcp_behavior.py
│ ├── test_mcp_tools.py
│ ├── test_nixhub.py
│ ├── test_nixos_stats.py
│ ├── test_options.py
│ ├── test_plain_text_output.py
│ ├── test_real_world_scenarios.py
│ ├── test_regression.py
│ └── test_server.py
├── uv.lock
└── website
├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode
│ └── settings.json
├── app
│ ├── about
│ │ └── page.tsx
│ ├── docs
│ │ └── claude.html
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx
│ ├── test-code-block
│ │ └── page.tsx
│ └── usage
│ └── page.tsx
├── components
│ ├── AnchorHeading.tsx
│ ├── ClientFooter.tsx
│ ├── ClientNavbar.tsx
│ ├── CodeBlock.tsx
│ ├── CollapsibleSection.tsx
│ ├── FeatureCard.tsx
│ ├── Footer.tsx
│ └── Navbar.tsx
├── metadata-checker.html
├── netlify.toml
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── favicon
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── apple-touch-icon.png
│ │ ├── browserconfig.xml
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon.ico
│ │ ├── mstile-150x150.png
│ │ ├── README.md
│ │ └── site.webmanifest
│ ├── images
│ │ ├── .gitkeep
│ │ ├── attribution.md
│ │ ├── claude-logo.png
│ │ ├── JamesBrink.jpeg
│ │ ├── mcp-nixos.png
│ │ ├── nixos-snowflake-colour.svg
│ │ ├── og-image.png
│ │ ├── sean-callan.png
│ │ └── utensils-logo.png
│ ├── robots.txt
│ └── sitemap.xml
├── README.md
├── tailwind.config.js
├── tsconfig.json
└── windsurf_deployment.yaml
```
# Files
--------------------------------------------------------------------------------
/tests/test_real_world_scenarios.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""Real-world scenario tests based on actual MCP tool usage patterns."""
from unittest.mock import Mock, patch
import pytest
from mcp_nixos import server
def get_tool_function(tool_name: str):
"""Get the underlying function from a FastMCP tool."""
tool = getattr(server, tool_name)
if hasattr(tool, "fn"):
return tool.fn
return tool
# Get the underlying functions for direct use
darwin_options_by_prefix = get_tool_function("darwin_options_by_prefix")
darwin_search = get_tool_function("darwin_search")
home_manager_info = get_tool_function("home_manager_info")
home_manager_options_by_prefix = get_tool_function("home_manager_options_by_prefix")
home_manager_search = get_tool_function("home_manager_search")
home_manager_stats = get_tool_function("home_manager_stats")
nixos_channels = get_tool_function("nixos_channels")
nixos_info = get_tool_function("nixos_info")
nixos_search = get_tool_function("nixos_search")
nixos_stats = get_tool_function("nixos_stats")
class TestRealWorldScenarios:
"""Test scenarios based on real user interactions with the MCP tools."""
@pytest.mark.asyncio
async def test_scenario_installing_development_tools(self):
"""User wants to set up a development environment with Git."""
# Step 1: Search for Git package
with patch("mcp_nixos.server.es_query") as mock_es:
mock_es.return_value = [
{
"_source": {
"type": "package",
"package_pname": "git",
"package_pversion": "2.49.0",
"package_description": "Distributed version control system",
}
}
]
result = await nixos_search("git")
assert "git (2.49.0)" in result
assert "Distributed version control system" in result
# Step 2: Get package details
with patch("mcp_nixos.server.es_query") as mock_es:
mock_es.return_value = [
{
"_source": {
"type": "package",
"package_pname": "git",
"package_pversion": "2.49.0",
"package_description": "Distributed version control system",
"package_homepage": ["https://git-scm.com/"],
}
}
]
result = await nixos_info("git")
assert "Package: git" in result
assert "Homepage: https://git-scm.com/" in result
# Step 3: Configure Git in Home Manager
# First, discover available options
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean", "description": "Whether to enable Git"},
{"name": "programs.git.userName", "type": "string", "description": "Default user name"},
{"name": "programs.git.userEmail", "type": "string", "description": "Default user email"},
]
result = await home_manager_options_by_prefix("programs.git")
assert "programs.git.enable" in result
assert "programs.git.userName" in result
# Step 4: Get specific option details
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{
"name": "programs.git.enable",
"type": "boolean",
"description": "Whether to enable Git",
}
]
result = await home_manager_info("programs.git.enable")
assert "Type: boolean" in result
@pytest.mark.asyncio
async def test_scenario_migrating_nixos_channels(self):
"""User wants to understand and migrate between NixOS channels."""
# Step 1: Check available channels (note: 24.11 removed from version list as EOL)
with patch("mcp_nixos.server.channel_cache.get_available") as mock_discover:
mock_discover.return_value = {
"latest-43-nixos-25.05": "151,698 documents",
"latest-43-nixos-25.11": "152,000 documents",
"latest-43-nixos-unstable": "151,798 documents",
}
# Mock that we're not using fallback
from mcp_nixos.server import channel_cache
channel_cache.using_fallback = False
result = await nixos_channels()
assert "stable (current: 25.05)" in result or "stable (current: 25.11)" in result
assert "25.05" in result or "25.11" in result
assert "unstable" in result
# Step 2: Compare package availability across channels
channels_to_test = ["stable", "25.05", "unstable"]
for channel in channels_to_test:
with patch("mcp_nixos.server.get_channels") as mock_get:
mock_get.return_value = {
"stable": "latest-43-nixos-25.05",
"25.05": "latest-43-nixos-25.05",
"25.11": "latest-43-nixos-25.11",
"unstable": "latest-43-nixos-unstable",
}
with patch("mcp_nixos.server.es_query") as mock_es:
mock_es.return_value = []
result = await nixos_search("firefox", channel=channel)
# Should work with all valid channels
assert "Error" not in result or "Invalid channel" not in result
@pytest.mark.asyncio
async def test_scenario_configuring_macos_with_darwin(self):
"""User wants to configure macOS system settings."""
# Step 1: Search for dock settings
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{
"name": "system.defaults.dock.autohide",
"type": "boolean",
"description": "Whether to automatically hide the dock",
}
]
result = await darwin_search("dock autohide")
assert "system.defaults.dock.autohide" in result
# Step 2: Browse all dock options
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide dock"},
{"name": "system.defaults.dock.autohide-delay", "type": "float", "description": "Auto-hide delay"},
{"name": "system.defaults.dock.orientation", "type": "string", "description": "Dock position"},
{"name": "system.defaults.dock.show-recents", "type": "boolean", "description": "Show recent apps"},
]
result = await darwin_options_by_prefix("system.defaults.dock")
assert "system.defaults.dock.autohide" in result
assert "system.defaults.dock.orientation" in result
@pytest.mark.asyncio
async def test_scenario_discovering_program_options(self):
"""User exploring what programs can be configured in Home Manager."""
# Step 1: Search for shell configuration
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "programs.zsh.enable", "type": "boolean", "description": "Whether to enable zsh"},
{"name": "programs.bash.enable", "type": "boolean", "description": "Whether to enable bash"},
{"name": "programs.fish.enable", "type": "boolean", "description": "Whether to enable fish"},
]
result = await home_manager_search("shell")
# At least one shell option should be found
assert any(shell in result for shell in ["zsh", "bash", "fish"])
# Step 2: Explore specific shell options
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "programs.zsh.enable", "type": "boolean", "description": "Whether to enable zsh"},
{"name": "programs.zsh.oh-my-zsh.enable", "type": "boolean", "description": "Enable oh-my-zsh"},
{"name": "programs.zsh.oh-my-zsh.theme", "type": "string", "description": "oh-my-zsh theme"},
{"name": "programs.zsh.shellAliases", "type": "attribute set", "description": "Shell aliases"},
]
result = await home_manager_options_by_prefix("programs.zsh")
assert "programs.zsh.oh-my-zsh.enable" in result
assert "programs.zsh.shellAliases" in result
@pytest.mark.asyncio
async def test_scenario_invalid_option_names(self):
"""Test what happens when users provide invalid option names."""
# Common mistake: using partial names
test_cases = [
("programs.git", "programs.git.enable"), # Missing .enable
("git", "programs.git.enable"), # Missing programs prefix
("system", "system.defaults"), # Too generic
]
for invalid_name, _ in test_cases:
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [] # No exact match
result = await home_manager_info(invalid_name)
assert "not found" in result.lower()
@pytest.mark.asyncio
async def test_scenario_exploring_available_packages_by_type(self):
"""User wants to find packages by category."""
# Search for different types of packages
package_types = [
("editor", ["neovim", "vim", "emacs"]),
("browser", ["firefox", "chromium"]),
("terminal", ["alacritty", "kitty", "wezterm"]),
]
for search_term, expected_packages in package_types:
with patch("mcp_nixos.server.es_query") as mock_es:
# Return at least one expected package
mock_es.return_value = [
{
"_source": {
"type": "package",
"package_pname": expected_packages[0],
"package_pversion": "1.0.0",
"package_description": f"A {search_term}",
}
}
]
result = await nixos_search(search_term)
assert any(pkg in result for pkg in expected_packages)
@pytest.mark.asyncio
async def test_scenario_understanding_option_types(self):
"""User needs to understand different option types in configurations."""
# Different option types in Home Manager
option_examples = [
("programs.git.enable", "boolean", "true/false value"),
("programs.git.userName", "string", "text value"),
("home.packages", "list of package", "list of packages"),
("programs.git.aliases", "attribute set of string", "key-value pairs"),
(
"services.dunst.settings",
"attribute set of (attribute set of (string or signed integer or boolean))",
"complex nested structure",
),
]
for option_name, type_str, _ in option_examples:
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{
"name": option_name,
"type": type_str,
"description": "Test option",
}
]
result = await home_manager_info(option_name)
assert f"Type: {type_str}" in result
@pytest.mark.asyncio
async def test_scenario_channel_suggestions_for_typos(self):
"""User makes typos in channel names and needs suggestions."""
typo_tests = [
("stabel", ["stable"]), # Typo
("25.11", ["25.05", "24.11"]), # Future version
("nixos-24.11", ["24.11"]), # Wrong format
]
for typo, expected_suggestions in typo_tests:
with patch("mcp_nixos.server.get_channels") as mock_get:
mock_get.return_value = {
"stable": "latest-43-nixos-25.05",
"unstable": "latest-43-nixos-unstable",
"25.05": "latest-43-nixos-25.05",
"24.11": "latest-43-nixos-24.11",
}
result = await nixos_search("test", channel=typo)
assert "Invalid channel" in result
assert "Available channels:" in result
# At least one suggestion should be present
assert any(sug in result for sug in expected_suggestions)
@pytest.mark.asyncio
async def test_scenario_performance_with_wildcards(self):
"""User uses wildcards in searches."""
# NixOS option search with wildcards
with patch("mcp_nixos.server.es_query") as mock_es:
mock_es.return_value = [
{
"_source": {
"type": "option",
"option_name": "services.nginx.enable",
"option_type": "boolean",
"option_description": "Whether to enable nginx",
}
}
]
# Search for options with wildcards
result = await nixos_search("*.nginx.*", search_type="options")
assert "services.nginx.enable" in result
@pytest.mark.asyncio
async def test_scenario_stats_usage_patterns(self):
"""User wants to understand the scale of available packages/options."""
# Get stats for different channels
with patch("mcp_nixos.server.get_channels") as mock_get:
mock_get.return_value = {
"unstable": "latest-43-nixos-unstable",
"stable": "latest-43-nixos-25.05",
}
with patch("requests.post") as mock_post:
mock_resp = Mock()
mock_resp.status_code = 200
mock_resp.json.side_effect = [
{"count": 129865}, # packages
{"count": 21933}, # options
]
mock_resp.raise_for_status.return_value = None
mock_post.return_value = mock_resp
result = await nixos_stats("unstable")
assert "129,865" in result # Formatted number
assert "21,933" in result
# Stats functions now return actual statistics
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
# Mock parsed options
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean", "description": "Enable git"},
{"name": "programs.zsh.enable", "type": "boolean", "description": "Enable zsh"},
{"name": "services.gpg-agent.enable", "type": "boolean", "description": "Enable GPG agent"},
{"name": "home.packages", "type": "list", "description": "Packages to install"},
{"name": "wayland.windowManager.sway.enable", "type": "boolean", "description": "Enable Sway"},
{"name": "xsession.enable", "type": "boolean", "description": "Enable X session"},
]
result = await home_manager_stats()
assert "Home Manager Statistics:" in result
assert "Total options:" in result
assert "Categories:" in result
assert "Top categories:" in result
```
--------------------------------------------------------------------------------
/tests/test_regression.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""Comprehensive tests for the fixes to issues found in Claude Desktop testing."""
from unittest.mock import MagicMock, patch
import pytest
from mcp_nixos import server
def get_tool_function(tool_name: str):
"""Get the underlying function from a FastMCP tool."""
tool = getattr(server, tool_name)
if hasattr(tool, "fn"):
return tool.fn
return tool
# Get the underlying functions for direct use
darwin_stats = get_tool_function("darwin_stats")
home_manager_stats = get_tool_function("home_manager_stats")
nixos_flakes_search = get_tool_function("nixos_flakes_search")
class TestFlakeSearchDeduplication:
"""Test that flake search properly deduplicates results."""
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_flake_search_deduplicates_packages(self, mock_post):
"""Test that multiple packages from same flake are grouped."""
# Mock response with duplicate flakes (different packages)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"hits": {
"hits": [
{
"_source": {
"flake_name": "home-manager",
"flake_description": "Home Manager for Nix",
"flake_resolved": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github",
},
"package_attr_name": "default",
}
},
{
"_source": {
"flake_name": "home-manager",
"flake_description": "Home Manager for Nix",
"flake_resolved": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github",
},
"package_attr_name": "docs-json",
}
},
{
"_source": {
"flake_name": "home-manager",
"flake_description": "Home Manager for Nix",
"flake_resolved": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github",
},
"package_attr_name": "docs-html",
}
},
]
}
}
mock_post.return_value = mock_response
result = await nixos_flakes_search("home-manager", limit=10)
# Should only show 1 unique flake
assert "Found 1 unique flakes matching 'home-manager':" in result
assert result.count("• home-manager") == 1
# Should show all packages together
assert "Packages: default, docs-html, docs-json" in result
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_flake_search_handles_many_packages(self, mock_post):
"""Test that flakes with many packages are handled properly."""
# Create a flake with 10 packages
hits = []
for i in range(10):
hits.append(
{
"_source": {
"flake_name": "multi-package-flake",
"flake_description": "A flake with many packages",
"flake_resolved": {
"owner": "test",
"repo": "multi-flake",
"type": "github",
},
"package_attr_name": f"package{i}",
}
}
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"hits": {"hits": hits}}
mock_post.return_value = mock_response
result = await nixos_flakes_search("multi-package", limit=20)
# Should show only first 5 packages with total count
assert "Found 1 unique flakes matching 'multi-package':" in result
assert "Packages: package0, package1, package2, package3, package4, ... (10 total)" in result
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_flake_search_handles_mixed_flakes(self, mock_post):
"""Test deduplication with multiple different flakes."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"hits": {
"hits": [
# home-manager with 2 packages
{
"_source": {
"flake_name": "home-manager",
"flake_description": "Home Manager for Nix",
"flake_resolved": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github",
},
"package_attr_name": "default",
}
},
{
"_source": {
"flake_name": "home-manager",
"flake_description": "Home Manager for Nix",
"flake_resolved": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github",
},
"package_attr_name": "docs-json",
}
},
# nixpkgs with 1 package
{
"_source": {
"flake_name": "nixpkgs",
"flake_description": "Nix Packages collection",
"flake_resolved": {
"owner": "NixOS",
"repo": "nixpkgs",
"type": "github",
},
"package_attr_name": "hello",
}
},
]
}
}
mock_post.return_value = mock_response
result = await nixos_flakes_search("test", limit=10)
# Should show 2 unique flakes
assert "Found 2 unique flakes matching 'test':" in result
assert result.count("• home-manager") == 1
assert result.count("• nixpkgs") == 1
# home-manager should show 2 packages
assert "default, docs-json" in result
# nixpkgs should show 1 package
assert "hello" in result
class TestHomeManagerStats:
"""Test improved home_manager_stats functionality."""
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_stats_returns_statistics(self, mock_parse):
"""Test that home_manager_stats returns actual statistics."""
# Mock parsed options
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean", "description": "Enable git"},
{"name": "programs.vim.enable", "type": "boolean", "description": "Enable vim"},
{"name": "services.dunst.enable", "type": "boolean", "description": "Enable dunst"},
{"name": "home.username", "type": "string", "description": "Username"},
{"name": "home.packages", "type": "list of packages", "description": "Packages"},
{"name": "wayland.enable", "type": "null or boolean", "description": "Enable wayland"},
]
result = await home_manager_stats()
# Should return statistics, not redirect message
assert "Home Manager Statistics:" in result
assert "Total options: 6" in result
assert "Categories: 4" in result
assert "programs: 2 options" in result
assert "services: 1 options" in result
assert "home: 2 options" in result
assert "wayland: 1 options" in result
# Should not contain the old redirect message
assert "require parsing the full documentation" not in result
assert "Use home_manager_list_options" not in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_stats_handles_errors(self, mock_parse):
"""Test that home_manager_stats handles errors gracefully."""
mock_parse.side_effect = Exception("Network error")
result = await home_manager_stats()
assert "Error (ERROR): Network error" in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_stats_handles_no_options(self, mock_parse):
"""Test that home_manager_stats handles empty results."""
mock_parse.return_value = []
result = await home_manager_stats()
assert "Error (ERROR): Failed to fetch Home Manager statistics" in result
class TestDarwinStats:
"""Test improved darwin_stats functionality."""
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_darwin_stats_returns_statistics(self, mock_parse):
"""Test that darwin_stats returns actual statistics."""
# Mock parsed options
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide dock"},
{
"name": "system.defaults.NSGlobalDomain.AppleShowAllFiles",
"type": "boolean",
"description": "Show all files",
},
{"name": "services.nix-daemon.enable", "type": "boolean", "description": "Enable nix-daemon"},
{"name": "programs.zsh.enable", "type": "boolean", "description": "Enable zsh"},
{"name": "homebrew.enable", "type": "boolean", "description": "Enable homebrew"},
{"name": "launchd.agents.test", "type": "attribute set", "description": "Test agent"},
]
result = await darwin_stats()
# Should return statistics, not redirect message
assert "nix-darwin Statistics:" in result
assert "Total options: 6" in result
assert "Categories: 5" in result
assert "services: 1 options" in result
assert "system: 2 options" in result
assert "programs: 1 options" in result
assert "homebrew: 1 options" in result
assert "launchd: 1 options" in result
# Should not contain the old redirect message
assert "require parsing the full documentation" not in result
assert "Use darwin_list_options" not in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_darwin_stats_handles_errors(self, mock_parse):
"""Test that darwin_stats handles errors gracefully."""
mock_parse.side_effect = Exception("Connection timeout")
result = await darwin_stats()
assert "Error (ERROR): Connection timeout" in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_darwin_stats_handles_no_options(self, mock_parse):
"""Test that darwin_stats handles empty results."""
mock_parse.return_value = []
result = await darwin_stats()
assert "Error (ERROR): Failed to fetch nix-darwin statistics" in result
class TestIntegration:
"""Integration tests for all fixes."""
@pytest.mark.integration
@pytest.mark.asyncio
async def test_flake_search_real_deduplication(self):
"""Test flake deduplication against real API."""
result = await nixos_flakes_search("home-manager", limit=20)
# Count how many times "• home-manager" appears
# Should be 1 after deduplication
home_manager_count = result.count("• home-manager")
assert home_manager_count <= 1, f"home-manager appears {home_manager_count} times, should be deduplicated"
# If found, should show packages
if "• home-manager" in result:
assert "Repository: nix-community/home-manager" in result
assert "Packages:" in result or "Package:" in result
@pytest.mark.integration
@pytest.mark.slow
@pytest.mark.asyncio
async def test_home_manager_stats_real_data(self):
"""Test home_manager_stats with real data."""
result = await home_manager_stats()
# Should return real statistics
assert "Home Manager Statistics:" in result
assert "Total options:" in result
assert "Categories:" in result
assert "programs:" in result
assert "services:" in result
# Should have reasonable numbers
assert "Total options: 0" not in result # Should have some options
assert "Categories: 0" not in result # Should have some categories
@pytest.mark.integration
@pytest.mark.slow
@pytest.mark.asyncio
async def test_darwin_stats_real_data(self):
"""Test darwin_stats with real data."""
result = await darwin_stats()
# Should return real statistics
assert "nix-darwin Statistics:" in result
assert "Total options:" in result
assert "Categories:" in result
assert "services:" in result
assert "system:" in result
# Should have reasonable numbers
assert "Total options: 0" not in result # Should have some options
assert "Categories: 0" not in result # Should have some categories
# Quick smoke test
if __name__ == "__main__":
print("Running comprehensive tests for fixes...")
# Test flake deduplication
test = TestFlakeSearchDeduplication()
with patch("requests.post") as mock_post:
# Set up mock response
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {
"hits": {
"hits": [
{"_source": {"flake_resolved": {"url": "github:user/repo1"}, "package_pname": "pkg1"}},
{"_source": {"flake_resolved": {"url": "github:user/repo1"}, "package_pname": "pkg2"}},
{"_source": {"flake_resolved": {"url": "github:user/repo2"}, "package_pname": "pkg3"}},
]
}
}
test.test_flake_search_deduplicates_packages(mock_post)
print("✓ Flake deduplication test passed")
# Test stats improvements
test_hm = TestHomeManagerStats()
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
# Set up mock response
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean"},
{"name": "programs.neovim.enable", "type": "boolean"},
{"name": "services.gpg-agent.enable", "type": "boolean"},
]
test_hm.test_home_manager_stats_returns_statistics(mock_parse)
print("✓ Home Manager stats test passed")
test_darwin = TestDarwinStats()
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
# Set up mock response
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "boolean"},
{"name": "services.nix-daemon.enable", "type": "boolean"},
]
test_darwin.test_darwin_stats_returns_statistics(mock_parse)
print("✓ Darwin stats test passed")
print("\nAll tests passed!")
```
--------------------------------------------------------------------------------
/tests/test_integration.py:
--------------------------------------------------------------------------------
```python
"""Real integration tests that verify actual API responses."""
import pytest
from mcp_nixos import server
def get_tool_function(tool_name: str):
"""Get the underlying function from a FastMCP tool."""
tool = getattr(server, tool_name)
if hasattr(tool, "fn"):
return tool.fn
return tool
# Get the underlying functions for direct use
darwin_info = get_tool_function("darwin_info")
darwin_options_by_prefix = get_tool_function("darwin_options_by_prefix")
darwin_search = get_tool_function("darwin_search")
home_manager_info = get_tool_function("home_manager_info")
home_manager_list_options = get_tool_function("home_manager_list_options")
home_manager_search = get_tool_function("home_manager_search")
home_manager_options_by_prefix = get_tool_function("home_manager_options_by_prefix")
nixos_info = get_tool_function("nixos_info")
nixos_search = get_tool_function("nixos_search")
nixos_stats = get_tool_function("nixos_stats")
@pytest.mark.integration
class TestRealIntegration:
"""Test against real APIs to ensure implementation works."""
@pytest.mark.asyncio
async def test_nixos_search_real(self):
"""Test real NixOS package search."""
result = await nixos_search("firefox", search_type="packages", limit=3)
assert "Found" in result
assert "firefox" in result
assert "•" in result # Bullet point
assert "(" in result # Version in parentheses
assert "<" not in result # No XML
@pytest.mark.asyncio
async def test_nixos_info_real(self):
"""Test real NixOS package info."""
result = await nixos_info("firefox", type="package")
assert "Package: firefox" in result
assert "Version:" in result
assert "Description:" in result
assert "<" not in result # No XML
@pytest.mark.asyncio
async def test_nixos_option_search_real(self):
"""Test real NixOS option search."""
result = await nixos_search("nginx", search_type="options", limit=3)
# Should find nginx options (now using wildcard, may find options with nginx anywhere)
assert "nginx" in result.lower() or "No options found" in result
assert "<" not in result # No XML
@pytest.mark.asyncio
async def test_nixos_option_info_real(self):
"""Test real NixOS option info."""
# Test with a common option that should exist
result = await nixos_info("services.nginx.enable", type="option")
if "NOT_FOUND" not in result:
assert "Option: services.nginx.enable" in result
assert "Type:" in result
assert "<" not in result # No XML
else:
# If not found, try another common option
result = await nixos_info("boot.loader.grub.enable", type="option")
if "NOT_FOUND" not in result:
assert "Option: boot.loader.grub.enable" in result
@pytest.mark.asyncio
async def test_nixos_stats_real(self):
"""Test real NixOS stats."""
result = await nixos_stats()
assert "NixOS Statistics" in result
assert "Packages:" in result
assert "Options:" in result
assert "<" not in result # No XML
@pytest.mark.asyncio
async def test_home_manager_search_real(self):
"""Test real Home Manager search."""
result = await home_manager_search("git", limit=3)
# Should find git-related options
assert "git" in result.lower() or "No Home Manager options found" in result
assert "<" not in result # No XML
@pytest.mark.asyncio
async def test_home_manager_info_real(self):
"""Test real Home Manager info."""
result = await home_manager_info("programs.git.enable")
assert "Option: programs.git.enable" in result or "not found" in result
assert "<" not in result # No XML
@pytest.mark.asyncio
async def test_darwin_search_real(self):
"""Test real Darwin search."""
result = await darwin_search("dock", limit=3)
# Should find dock-related options
assert "dock" in result.lower() or "No nix-darwin options found" in result
# Allow <name> as it's a placeholder, not XML
if "<" in result:
assert "<name>" in result # This is OK, it's a placeholder
assert "</" not in result # No closing XML tags
@pytest.mark.asyncio
async def test_plain_text_format_consistency(self):
"""Ensure all outputs follow consistent plain text format."""
# Test various searches
results = [
await nixos_search("python", search_type="packages", limit=2),
await home_manager_search("shell", limit=2),
await darwin_search("system", limit=2),
]
for result in results:
# Check for common plain text patterns
if "Found" in result:
assert ":" in result # Colon after "Found X matching"
assert "•" in result # Bullet points for items
elif "No" in result:
assert "found" in result # "No X found"
# Ensure no XML tags
assert "<" not in result
assert ">" not in result
@pytest.mark.asyncio
async def test_error_handling_plain_text(self):
"""Test error messages are plain text."""
# Test with invalid type
result = await nixos_search("test", search_type="invalid")
assert "Error" in result
assert "<" not in result
# Test with invalid channel
result = await nixos_search("test", channel="invalid")
assert "Error" in result
assert "Invalid channel" in result
assert "<" not in result
# ===== Content from test_advanced_integration.py =====
@pytest.mark.integration
class TestAdvancedIntegration:
"""Test advanced scenarios with real APIs."""
@pytest.mark.asyncio
async def test_nixos_search_special_characters(self):
"""Test searching with special characters and symbols."""
# Test with hyphens
result = await nixos_search("ruby-build", search_type="packages")
assert "ruby-build" in result or "No packages found" in result
# Test with dots
result = await nixos_search("lib.so", search_type="packages")
# Should handle dots in search gracefully
assert "Error" not in result
# Test with underscores
result = await nixos_search("python3_12", search_type="packages")
assert "Error" not in result
@pytest.mark.asyncio
async def test_nixos_search_case_sensitivity(self):
"""Test case sensitivity in searches."""
# Search with different cases
result_lower = await nixos_search("firefox", search_type="packages", limit=5)
result_upper = await nixos_search("FIREFOX", search_type="packages", limit=5)
result_mixed = await nixos_search("FireFox", search_type="packages", limit=5)
# All should find firefox (case-insensitive search)
assert "firefox" in result_lower.lower()
assert "firefox" in result_upper.lower()
assert "firefox" in result_mixed.lower()
@pytest.mark.asyncio
async def test_nixos_option_hierarchical_search(self):
"""Test searching hierarchical option names."""
# Search for nested options
result = await nixos_search("systemd.services", search_type="options", limit=10)
assert "systemd.services" in result or "No options found" in result
# Search for deeply nested options
result = await nixos_search("networking.firewall.allowedTCPPorts", search_type="options", limit=5)
# Should handle long option names
assert "Error" not in result
@pytest.mark.asyncio
async def test_nixos_cross_channel_consistency(self):
"""Test that different channels return consistent data structure."""
channels = ["unstable", "stable"]
for channel in channels:
# Stats should work for all channels
stats = await nixos_stats(channel=channel)
assert "Packages:" in stats
assert "Options:" in stats
assert "Error" not in stats
# Search should return same structure
search = await nixos_search("git", search_type="packages", channel=channel, limit=3)
if "Found" in search:
assert "•" in search # Bullet points
assert "(" in search # Version in parentheses
@pytest.mark.asyncio
async def test_nixos_info_edge_packages(self):
"""Test info retrieval for packages with unusual names."""
# Test package with version in name
edge_packages = [
"python3", # Common package
"gcc", # Short name
"gnome.nautilus", # Namespaced package
]
for pkg in edge_packages:
result = await nixos_info(pkg, type="package")
if "not found" not in result:
assert "Package:" in result
assert "Version:" in result
@pytest.mark.asyncio
async def test_home_manager_search_complex_queries(self):
"""Test complex search patterns in Home Manager."""
# Search for options with dots
result = await home_manager_search("programs.git.delta", limit=10)
if "Found" in result:
assert "programs.git.delta" in result
# Search for options with underscores
result = await home_manager_search("enable_", limit=10)
# Should handle underscore in search
assert "Error" not in result
# Search for very short terms
result = await home_manager_search("qt", limit=5)
assert "Error" not in result
@pytest.mark.asyncio
async def test_home_manager_category_completeness(self):
"""Test that list_options returns all major categories."""
result = await home_manager_list_options()
# Check for expected major categories
expected_categories = ["programs", "services", "home", "xdg"]
for category in expected_categories:
assert category in result
# Verify format consistency
assert "total)" in result
assert "• " in result
assert " options)" in result
@pytest.mark.asyncio
async def test_home_manager_prefix_navigation(self):
"""Test navigating option hierarchy with prefixes."""
# Start with top-level
result = await home_manager_options_by_prefix("programs")
if "Found" not in result and "found)" in result:
# Drill down to specific program
result_git = await home_manager_options_by_prefix("programs.git")
if "found)" in result_git:
assert "programs.git" in result_git
# Drill down further
result_delta = await home_manager_options_by_prefix("programs.git.delta")
assert "Error" not in result_delta
@pytest.mark.asyncio
async def test_home_manager_info_name_variants(self):
"""Test info retrieval with different name formats."""
# Test with placeholder names
result = await home_manager_info("programs.firefox.profiles.<name>.settings")
# Should handle <name> placeholders
if "not found" not in result:
assert "Option:" in result
@pytest.mark.asyncio
async def test_darwin_search_macos_specific(self):
"""Test searching macOS-specific options."""
# Search for macOS-specific terms
macos_terms = ["homebrew", "launchd", "defaults", "dock"]
for term in macos_terms:
result = await darwin_search(term, limit=5)
if "Found" in result:
assert term in result.lower()
assert "•" in result
@pytest.mark.asyncio
async def test_darwin_system_defaults_exploration(self):
"""Test exploring system.defaults hierarchy."""
# List all system.defaults options
result = await darwin_options_by_prefix("system.defaults")
if "found)" in result:
# Should have many system defaults
assert "system.defaults" in result
# Test specific subcategories
subcategories = ["NSGlobalDomain", "dock", "finder"]
for subcat in subcategories:
sub_result = await darwin_options_by_prefix(f"system.defaults.{subcat}")
# Should not error even if no results
assert "Error" not in sub_result
@pytest.mark.asyncio
async def test_darwin_info_detailed_options(self):
"""Test retrieving detailed darwin option info."""
# Test well-known options
known_options = ["system.defaults.dock.autohide", "environment.systemPath", "programs.zsh.enable"]
for opt in known_options:
result = await darwin_info(opt)
if "not found" not in result:
assert "Option:" in result
# Darwin options often have descriptions
assert "Description:" in result or "Type:" in result
@pytest.mark.asyncio
async def test_performance_large_searches(self):
"""Test performance with large result sets."""
import time
# NixOS large search
start = time.time()
result = await nixos_search("lib", search_type="packages", limit=100)
elapsed = time.time() - start
assert elapsed < 30 # Should complete within 30 seconds
assert "Error" not in result
# Home Manager large listing
start = time.time()
result = await home_manager_list_options()
elapsed = time.time() - start
assert elapsed < 30 # HTML parsing should be reasonably fast
@pytest.mark.asyncio
async def test_concurrent_api_calls(self):
"""Test handling concurrent API calls."""
import asyncio
queries = ["python", "ruby", "nodejs", "rust", "go"]
# Run searches concurrently
tasks = [nixos_search(query, limit=5) for query in queries]
results = await asyncio.gather(*tasks)
# All searches should complete without errors
for result in results:
assert "Error" not in result or "No packages found" in result
@pytest.mark.asyncio
async def test_unicode_handling(self):
"""Test handling of unicode in searches and results."""
# Search with unicode
result = await nixos_search("文字", search_type="packages", limit=5)
# Should handle unicode gracefully
assert "Error" not in result
# Some packages might have unicode in descriptions
result = await nixos_info("font-awesome")
if "not found" not in result:
# Should display unicode properly if present
assert "Package:" in result
@pytest.mark.asyncio
async def test_empty_and_whitespace_queries(self):
"""Test handling of empty and whitespace-only queries."""
# Empty string
result = await nixos_search("", search_type="packages", limit=5)
assert "No packages found" in result or "Found" in result
# Whitespace only
result = await home_manager_search(" ", limit=5)
assert "Error" not in result
# Newlines and tabs
result = await darwin_search("\n\t", limit=5)
assert "Error" not in result
@pytest.mark.asyncio
async def test_option_type_complexity(self):
"""Test handling of complex option types."""
# Search for options with complex types
result = await nixos_search("extraConfig", search_type="options", limit=10)
if "Found" in result and "Type:" in result:
# Complex types like "null or string" should be handled
assert "Error" not in result
@pytest.mark.asyncio
async def test_api_timeout_resilience(self):
"""Test behavior with slow API responses."""
# This might occasionally fail if API is very slow
# Using programs type which might have more processing
result = await nixos_search("compiler", search_type="programs", limit=50)
# Should either succeed or timeout gracefully
assert "packages found" in result or "programs found" in result or "Error" in result
@pytest.mark.asyncio
async def test_html_parsing_edge_cases(self):
"""Test HTML parsing with real documentation quirks."""
# Test getting options that might have complex HTML
complex_prefixes = ["programs.neovim.plugins", "services.nginx.virtualHosts", "systemd.services"]
for prefix in complex_prefixes:
result = await home_manager_options_by_prefix(prefix)
# Should handle any HTML structure
assert "Error" not in result or "No Home Manager options found" in result
```
--------------------------------------------------------------------------------
/tests/test_nixos_stats.py:
--------------------------------------------------------------------------------
```python
"""Regression test for NixOS stats to ensure correct field names are used."""
from unittest.mock import Mock, patch
import pytest
from mcp_nixos import server
def get_tool_function(tool_name: str):
"""Get the underlying function from a FastMCP tool."""
tool = getattr(server, tool_name)
if hasattr(tool, "fn"):
return tool.fn
return tool
# Get the underlying functions for direct use
nixos_channels = get_tool_function("nixos_channels")
nixos_stats = get_tool_function("nixos_stats")
def setup_channel_mocks(mock_cache, mock_validate, channels=None):
"""Setup channel mocks with default or custom channels."""
if channels is None:
channels = {
"unstable": "latest-43-nixos-unstable",
"stable": "latest-43-nixos-25.05",
"25.05": "latest-43-nixos-25.05",
"24.11": "latest-43-nixos-24.11",
"beta": "latest-43-nixos-25.05",
}
mock_cache.get_available.return_value = {v: f"{v.split('-')[-1]} docs" for v in channels.values() if v}
mock_cache.get_resolved.return_value = channels
mock_validate.side_effect = lambda channel: channel in channels
class TestNixOSStatsRegression:
"""Ensure NixOS stats uses correct field names in queries."""
@patch("mcp_nixos.server.validate_channel")
@patch("mcp_nixos.server.channel_cache")
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_nixos_stats_uses_correct_query_fields(self, mock_post, mock_cache, mock_validate):
"""Test that stats uses 'type' field with term query, not 'package'/'option' with exists query."""
# Setup channel mocks
setup_channel_mocks(mock_cache, mock_validate)
# Mock responses
pkg_resp = Mock()
pkg_resp.json.return_value = {"count": 129865}
opt_resp = Mock()
opt_resp.json.return_value = {"count": 21933}
mock_post.side_effect = [pkg_resp, opt_resp]
# Call the function
result = await nixos_stats()
# Verify the function returns expected output
assert "NixOS Statistics for unstable channel:" in result
assert "• Packages: 129,865" in result
assert "• Options: 21,933" in result
# Verify the correct queries were sent
assert mock_post.call_count == 2
# Check package count query
pkg_call = mock_post.call_args_list[0]
assert pkg_call[1]["json"]["query"] == {"term": {"type": "package"}}
# Check option count query
opt_call = mock_post.call_args_list[1]
assert opt_call[1]["json"]["query"] == {"term": {"type": "option"}}
@patch("mcp_nixos.server.validate_channel")
@patch("mcp_nixos.server.channel_cache")
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_nixos_stats_handles_zero_counts(self, mock_post, mock_cache, mock_validate):
"""Test that stats correctly handles zero counts."""
# Setup channel mocks
setup_channel_mocks(mock_cache, mock_validate)
# Mock responses with zero counts
mock_resp = Mock()
mock_resp.json.return_value = {"count": 0}
mock_post.return_value = mock_resp
result = await nixos_stats()
# Should return error when both counts are zero (our improved logic)
assert "Error (ERROR): Failed to retrieve statistics" in result
@patch("mcp_nixos.server.validate_channel")
@patch("mcp_nixos.server.channel_cache")
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_nixos_stats_all_channels(self, mock_post, mock_cache, mock_validate):
"""Test that stats works for all defined channels."""
# Setup channel mocks
setup_channel_mocks(mock_cache, mock_validate)
# Mock responses
mock_resp = Mock()
mock_resp.json.return_value = {"count": 12345}
mock_post.return_value = mock_resp
# Test with known channels
for channel in ["stable", "unstable"]:
result = await nixos_stats(channel=channel)
assert f"NixOS Statistics for {channel} channel:" in result
assert "• Packages: 12,345" in result
assert "• Options: 12,345" in result
# ===== Content from test_package_counts_eval.py =====
class TestPackageCountsEval:
"""Test evaluations for getting package counts per NixOS channel."""
@patch("mcp_nixos.server.validate_channel")
@patch("mcp_nixos.server.channel_cache")
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_get_package_counts_per_channel(self, mock_post, mock_cache, mock_validate):
"""Eval: User wants package counts for each NixOS channel."""
# Setup channel mocks
setup_channel_mocks(mock_cache, mock_validate)
# Mock channel discovery responses
mock_count_responses = {
"latest-43-nixos-unstable": {"count": 151798},
"latest-43-nixos-25.05": {"count": 151698},
"latest-43-nixos-24.11": {"count": 142034},
}
# Mock stats responses for each channel
mock_stats_responses = {
"unstable": {
"aggregations": {
"attr_count": {"value": 151798},
"option_count": {"value": 20156},
"program_count": {"value": 3421},
"license_count": {"value": 125},
"maintainer_count": {"value": 3254},
"platform_counts": {
"buckets": [
{"key": "x86_64-linux", "doc_count": 145234},
{"key": "aarch64-linux", "doc_count": 142123},
{"key": "x86_64-darwin", "doc_count": 98765},
{"key": "aarch64-darwin", "doc_count": 97654},
]
},
}
},
"25.05": {
"aggregations": {
"attr_count": {"value": 151698},
"option_count": {"value": 20145},
"program_count": {"value": 3420},
"license_count": {"value": 125},
"maintainer_count": {"value": 3250},
"platform_counts": {
"buckets": [
{"key": "x86_64-linux", "doc_count": 145134},
{"key": "aarch64-linux", "doc_count": 142023},
{"key": "x86_64-darwin", "doc_count": 98665},
{"key": "aarch64-darwin", "doc_count": 97554},
]
},
}
},
"24.11": {
"aggregations": {
"attr_count": {"value": 142034},
"option_count": {"value": 19876},
"program_count": {"value": 3200},
"license_count": {"value": 123},
"maintainer_count": {"value": 3100},
"platform_counts": {
"buckets": [
{"key": "x86_64-linux", "doc_count": 138000},
{"key": "aarch64-linux", "doc_count": 135000},
{"key": "x86_64-darwin", "doc_count": 92000},
{"key": "aarch64-darwin", "doc_count": 91000},
]
},
}
},
}
def side_effect(*args, **kwargs):
url = args[0]
# Handle count requests for channel discovery
if "/_count" in url:
for index, count_data in mock_count_responses.items():
if index in url:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json = Mock(return_value=count_data)
mock_response.raise_for_status = Mock()
return mock_response
# Not found
mock_response = Mock()
mock_response.status_code = 404
mock_response.raise_for_status = Mock(side_effect=Exception("Not found"))
return mock_response
# Handle stats count requests (with type filter)
json_data = kwargs.get("json", {})
query = json_data.get("query", {})
# Determine which channel from URL
for channel, index in [
("unstable", "latest-43-nixos-unstable"),
("25.05", "latest-43-nixos-25.05"),
("24.11", "latest-43-nixos-24.11"),
]:
if index in url:
stats = mock_stats_responses.get(channel, mock_stats_responses["unstable"])
mock_response = Mock()
mock_response.status_code = 200
mock_response.raise_for_status = Mock()
# Check if it's a package or option count
if query.get("term", {}).get("type") == "package":
mock_response.json = Mock(return_value={"count": stats["aggregations"]["attr_count"]["value"]})
elif query.get("term", {}).get("type") == "option":
mock_response.json = Mock(
return_value={"count": stats["aggregations"]["option_count"]["value"]}
)
else:
# General count
mock_response.json = Mock(return_value={"count": stats["aggregations"]["attr_count"]["value"]})
return mock_response
# Default response - return a proper mock
mock_response = Mock()
mock_response.status_code = 200
mock_response.raise_for_status = Mock()
mock_response.json = Mock(return_value={"count": 151798})
return mock_response
mock_post.side_effect = side_effect
# Step 1: Get available channels
channels_result = await nixos_channels()
assert "24.11" in channels_result
assert "25.05" in channels_result
assert "unstable" in channels_result
# Check that document counts are present (don't hardcode exact values as they change)
assert "docs)" in channels_result
assert "Available" in channels_result
# Step 2: Get stats for each channel
stats_unstable = await nixos_stats("unstable")
assert "Packages:" in stats_unstable
assert "Options:" in stats_unstable
stats_stable = await nixos_stats("stable") # Should resolve to 25.05
assert "Packages:" in stats_stable
stats_24_11 = await nixos_stats("24.11")
assert "Packages:" in stats_24_11
# Verify package count differences
# unstable should have the most packages
# 25.05 (current stable) should be close to unstable
# 24.11 should have fewer packages
@patch("mcp_nixos.server.validate_channel")
@patch("mcp_nixos.server.channel_cache")
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_package_counts_with_beta_alias(self, mock_post, mock_cache, mock_validate):
"""Eval: User asks about beta channel package count."""
# Setup channel mocks
setup_channel_mocks(mock_cache, mock_validate)
# Mock responses for channel discovery
mock_count_response = Mock()
mock_count_response.status_code = 200
mock_count_response.json.return_value = {"count": 151698}
mock_stats_response = Mock()
mock_stats_response.json.return_value = {
"aggregations": {
"attr_count": {"value": 151698},
"option_count": {"value": 20145},
"program_count": {"value": 3420},
"license_count": {"value": 125},
"maintainer_count": {"value": 3250},
"platform_counts": {
"buckets": [
{"key": "x86_64-linux", "doc_count": 145134},
]
},
}
}
def side_effect(*args, **kwargs):
url = args[0]
if "/_count" in url and "25.05" in url:
return mock_count_response
if "/_count" in url:
# Other channels not found
mock_404 = Mock()
mock_404.status_code = 404
return mock_404
# Stats request
json_data = kwargs.get("json", {})
query = json_data.get("query", {})
mock_response = Mock()
mock_response.status_code = 200
# Check if it's a package or option count
if query.get("term", {}).get("type") == "package":
mock_response.json.return_value = {"count": 151698}
elif query.get("term", {}).get("type") == "option":
mock_response.json.return_value = {"count": 20145}
else:
# General count
mock_response.json.return_value = {"count": 151698}
return mock_response
mock_post.side_effect = side_effect
# Beta should resolve to stable (25.05)
result = await nixos_stats("beta")
assert "Packages:" in result
assert "beta" in result
@patch("mcp_nixos.server.validate_channel")
@patch("mcp_nixos.server.channel_cache")
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_compare_package_counts_across_channels(self, mock_post, mock_cache, mock_validate):
"""Eval: User wants to compare package growth across releases."""
# Setup channel mocks
setup_channel_mocks(mock_cache, mock_validate)
# Mock responses with increasing package counts
mock_count_responses = {
"latest-43-nixos-unstable": {"count": 151798},
"latest-43-nixos-25.05": {"count": 151698},
"latest-43-nixos-24.11": {"count": 142034},
"latest-43-nixos-24.05": {"count": 135000},
}
channel_stats = {
"24.05": 135000,
"24.11": 142034,
"25.05": 151698,
"unstable": 151798,
}
def side_effect(*args, **kwargs):
url = args[0]
# Handle count requests for channel discovery
if "/_count" in url:
for index, count_data in mock_count_responses.items():
if index in url:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json = Mock(return_value=count_data)
mock_response.raise_for_status = Mock()
return mock_response
# Not found
mock_response = Mock()
mock_response.status_code = 404
mock_response.raise_for_status = Mock(side_effect=Exception("Not found"))
return mock_response
# Handle stats count requests (with type filter)
json_data = kwargs.get("json", {})
query = json_data.get("query", {})
# Extract channel from URL and return appropriate stats
channel_to_index = {
"24.05": "latest-43-nixos-24.05",
"24.11": "latest-43-nixos-24.11",
"25.05": "latest-43-nixos-25.05",
"unstable": "latest-43-nixos-unstable",
}
for channel, count in channel_stats.items():
index = channel_to_index.get(channel)
if index and index in url:
mock_response = Mock()
mock_response.status_code = 200
mock_response.raise_for_status = Mock()
# Check if it's a package or option count
if query.get("term", {}).get("type") == "package":
mock_response.json = Mock(return_value={"count": count})
elif query.get("term", {}).get("type") == "option":
mock_response.json = Mock(return_value={"count": 20000})
else:
# General count
mock_response.json = Mock(return_value={"count": count})
return mock_response
# Default to unstable
mock_response = Mock()
mock_response.status_code = 200
mock_response.raise_for_status = Mock()
if query.get("term", {}).get("type") == "package":
mock_response.json = Mock(return_value={"count": 151798})
elif query.get("term", {}).get("type") == "option":
mock_response.json = Mock(return_value={"count": 20156})
else:
mock_response.json = Mock(return_value={"count": 151798})
return mock_response
mock_post.side_effect = side_effect
# Get stats for multiple channels to compare growth
# Only use channels that are currently available
for channel in ["24.11", "25.05", "unstable"]:
stats = await nixos_stats(channel)
# Just verify we get stats back with package info
assert "Packages:" in stats
assert "channel:" in stats.lower() # Check case-insensitively
```
--------------------------------------------------------------------------------
/tests/test_options.py:
--------------------------------------------------------------------------------
```python
"""Comprehensive tests for nixos_info option lookups."""
from unittest.mock import patch
import pytest
from mcp_nixos import server
def get_tool_function(tool_name: str):
"""Get the underlying function from a FastMCP tool."""
tool = getattr(server, tool_name)
if hasattr(tool, "fn"):
return tool.fn
return tool
# Get the underlying functions for direct use
darwin_info = get_tool_function("darwin_info")
darwin_stats = get_tool_function("darwin_stats")
home_manager_info = get_tool_function("home_manager_info")
home_manager_options_by_prefix = get_tool_function("home_manager_options_by_prefix")
home_manager_stats = get_tool_function("home_manager_stats")
nixos_info = get_tool_function("nixos_info")
class TestNixosInfoOptions:
"""Test nixos_info with option lookups."""
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_option_with_exact_match(self, mock_query):
"""Test info retrieval for exact option match."""
mock_query.return_value = [
{
"_source": {
"option_name": "services.nginx.enable",
"option_type": "boolean",
"option_description": "<rendered-html><p>Whether to enable Nginx Web Server.</p>\n</rendered-html>",
"option_default": "false",
"option_example": "true",
}
}
]
result = await nixos_info("services.nginx.enable", type="option")
# Verify the query
mock_query.assert_called_once()
query = mock_query.call_args[0][1]
assert query["bool"]["must"][0]["term"]["type"] == "option"
assert query["bool"]["must"][1]["term"]["option_name"] == "services.nginx.enable"
# Verify the result
assert "Option: services.nginx.enable" in result
assert "Type: boolean" in result
assert "Description: Whether to enable Nginx Web Server." in result
assert "Default: false" in result
assert "Example: true" in result
assert "<rendered-html>" not in result # HTML should be stripped
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_option_not_found(self, mock_query):
"""Test info when option is not found."""
mock_query.return_value = []
result = await nixos_info("services.nginx.nonexistent", type="option")
assert result == "Error (NOT_FOUND): Option 'services.nginx.nonexistent' not found"
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_option_with_minimal_fields(self, mock_query):
"""Test info with minimal option fields."""
mock_query.return_value = [
{
"_source": {
"option_name": "services.test.enable",
"option_description": "Enable test service",
}
}
]
result = await nixos_info("services.test.enable", type="option")
assert "Option: services.test.enable" in result
assert "Description: Enable test service" in result
# No type, default, or example should not cause errors
assert "Type:" not in result or "Type: " in result
assert "Default:" not in result or "Default: " in result
assert "Example:" not in result or "Example: " in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_option_complex_description(self, mock_query):
"""Test option with complex HTML description."""
mock_query.return_value = [
{
"_source": {
"option_name": "programs.zsh.enable",
"option_type": "boolean",
"option_description": (
"<rendered-html><p>Whether to configure <strong>zsh</strong> as an interactive shell. "
"See <a href='https://www.zsh.org/'>zsh docs</a>.</p></rendered-html>"
),
}
}
]
result = await nixos_info("programs.zsh.enable", type="option")
assert "Option: programs.zsh.enable" in result
assert "Type: boolean" in result
assert "Whether to configure zsh as an interactive shell" in result
assert "<strong>" not in result
assert "<a href=" not in result
assert "</p>" not in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_option_hierarchical_names(self, mock_query):
"""Test options with deeply nested hierarchical names."""
test_cases = [
"services.xserver.displayManager.gdm.enable",
"networking.firewall.allowedTCPPorts",
"users.users.root.hashedPassword",
"boot.loader.systemd-boot.enable",
]
for option_name in test_cases:
mock_query.return_value = [
{
"_source": {
"option_name": option_name,
"option_type": "test-type",
"option_description": f"Test option: {option_name}",
}
}
]
result = await nixos_info(option_name, type="option")
# Verify query uses correct field
query = mock_query.call_args[0][1]
assert query["bool"]["must"][1]["term"]["option_name"] == option_name
# Verify result
assert f"Option: {option_name}" in result
assert f"Test option: {option_name}" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_option_api_error(self, mock_query):
"""Test error handling for API failures."""
mock_query.side_effect = Exception("Connection timeout")
result = await nixos_info("services.nginx.enable", type="option")
assert "Error (ERROR): Connection timeout" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_option_empty_fields(self, mock_query):
"""Test handling of empty option fields."""
mock_query.return_value = [
{
"_source": {
"option_name": "test.option",
"option_type": "",
"option_description": "",
"option_default": "",
"option_example": "",
}
}
]
result = await nixos_info("test.option", type="option")
assert "Option: test.option" in result
# Empty fields should not appear in output
lines = result.split("\n")
for line in lines:
if ":" in line and line != "Option: test.option":
_, value = line.split(":", 1)
assert value.strip() != "" # No empty values after colon
@pytest.mark.integration
class TestNixosInfoOptionsIntegration:
"""Integration tests against real NixOS API."""
@pytest.mark.asyncio
async def test_real_option_lookup_services_nginx_enable(self):
"""Test real lookup of services.nginx.enable."""
result = await nixos_info("services.nginx.enable", type="option")
if "NOT_FOUND" in result:
# If not found, it might be due to API changes
pytest.skip("Option services.nginx.enable not found in current channel")
assert "Option: services.nginx.enable" in result
assert "Type: boolean" in result
assert "nginx" in result.lower() or "web server" in result.lower()
@pytest.mark.asyncio
async def test_real_option_lookup_common_options(self):
"""Test real lookup of commonly used options."""
common_options = [
"boot.loader.grub.enable",
"networking.hostName",
"services.openssh.enable",
"users.users",
]
for option_name in common_options:
result = await nixos_info(option_name, type="option")
# These options should exist
if "NOT_FOUND" not in result:
assert f"Option: {option_name}" in result
assert "Type:" in result or "Description:" in result
@pytest.mark.asyncio
async def test_real_option_not_found(self):
"""Test real lookup of non-existent option."""
result = await nixos_info("services.completely.fake.option", type="option")
assert "Error (NOT_FOUND):" in result
assert "services.completely.fake.option" in result
# ===== Content from test_nixos_info_option_evals.py =====
class TestNixosInfoOptionEvals:
"""Evaluation tests for nixos_info with options."""
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_eval_services_nginx_enable_info(self, mock_query):
"""Evaluate getting info about services.nginx.enable option."""
# Mock the API response
mock_query.return_value = [
{
"_source": {
"option_name": "services.nginx.enable",
"option_type": "boolean",
"option_description": "<rendered-html><p>Whether to enable Nginx Web Server.</p>\n</rendered-html>",
"option_default": "false",
"option_example": "true",
}
}
]
# User query equivalent: "Get details about services.nginx.enable"
result = await nixos_info("services.nginx.enable", type="option")
# Expected behaviors:
# 1. Should use correct option name without .keyword suffix
# 2. Should display option info clearly
# 3. Should strip HTML tags from description
# 4. Should show all available fields
# Verify the query
assert mock_query.called
query = mock_query.call_args[0][1]
assert query["bool"]["must"][1]["term"]["option_name"] == "services.nginx.enable"
assert "option_name.keyword" not in str(query)
# Verify output format
assert "Option: services.nginx.enable" in result
assert "Type: boolean" in result
assert "Description: Whether to enable Nginx Web Server." in result
assert "Default: false" in result
assert "Example: true" in result
# Verify HTML stripping
assert "<rendered-html>" not in result
assert "</p>" not in result
assert "<p>" not in result
# Verify it's plain text
assert all(char not in result for char in ["<", ">"] if char not in ["<name>"])
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_eval_nested_option_lookup(self, mock_query):
"""Evaluate looking up deeply nested options."""
# Mock response for nested option
mock_query.return_value = [
{
"_source": {
"option_name": "services.xserver.displayManager.gdm.enable",
"option_type": "boolean",
"option_description": "Whether to enable the GDM display manager",
"option_default": "false",
}
}
]
# User query: "Show me the services.xserver.displayManager.gdm.enable option"
result = await nixos_info("services.xserver.displayManager.gdm.enable", type="option")
# Expected: should handle long hierarchical names correctly
assert "Option: services.xserver.displayManager.gdm.enable" in result
assert "Type: boolean" in result
assert "GDM display manager" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_eval_option_not_found_behavior(self, mock_query):
"""Evaluate behavior when option is not found."""
# Mock empty response
mock_query.return_value = []
# User query: "Get info about services.fake.option"
result = await nixos_info("services.fake.option", type="option")
# Expected: clear error message
assert "Error (NOT_FOUND):" in result
assert "services.fake.option" in result
assert "Option" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_eval_common_options_lookup(self, mock_query):
"""Evaluate looking up commonly used NixOS options."""
common_options = [
("boot.loader.grub.enable", "boolean", "Whether to enable the GRUB boot loader"),
("networking.hostName", "string", "The hostname of the machine"),
("services.openssh.enable", "boolean", "Whether to enable the OpenSSH daemon"),
("users.users.<name>.home", "path", "The user's home directory"),
]
for option_name, option_type, description in common_options:
mock_query.return_value = [
{
"_source": {
"option_name": option_name,
"option_type": option_type,
"option_description": description,
}
}
]
result = await nixos_info(option_name, type="option")
# Verify each option is handled correctly
assert f"Option: {option_name}" in result
assert f"Type: {option_type}" in result
assert description in result or description.replace("<name>", "_name_") in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_eval_option_with_complex_html(self, mock_query):
"""Evaluate handling of options with complex HTML descriptions."""
mock_query.return_value = [
{
"_source": {
"option_name": "programs.firefox.policies",
"option_type": "attribute set",
"option_description": (
"<rendered-html>"
"<p>Firefox policies configuration. See "
"<a href='https://github.com/mozilla/policy-templates'>Mozilla Policy Templates</a> "
"for available options. You can use <code>lib.mkForce</code> to override.</p>"
"<p><strong>Note:</strong> This requires Firefox ESR or Firefox with "
"enterprise policy support.</p>"
"</rendered-html>"
),
}
}
]
result = await nixos_info("programs.firefox.policies", type="option")
# Should clean up HTML nicely
assert "Option: programs.firefox.policies" in result
assert "Firefox policies configuration" in result
assert "Mozilla Policy Templates" in result
# No HTML artifacts
assert "<rendered-html>" not in result
assert "<p>" not in result
assert "<a href=" not in result
assert "<strong>" not in result
assert "</p>" not in result
@pytest.mark.integration
@pytest.mark.asyncio
async def test_eval_real_option_lookup_integration(self):
"""Integration test: evaluate real option lookup behavior."""
# Test with a real option that should exist
result = await nixos_info("services.nginx.enable", type="option")
if "NOT_FOUND" not in result:
# If found (API is available)
assert "Option: services.nginx.enable" in result
assert "Type:" in result # Should have a type
assert "nginx" in result.lower() or "web server" in result.lower()
# No XML/HTML
assert "<" not in result
assert ">" not in result
else:
# If not found, verify error format
assert "Error (NOT_FOUND):" in result
assert "services.nginx.enable" in result
# ===== Content from test_option_info_improvements.py =====
class TestOptionInfoImprovements:
"""Test improvements to option info lookup based on real usage."""
@pytest.mark.asyncio
async def test_home_manager_info_requires_exact_match(self):
"""Test that home_manager_info requires exact option names."""
# User tries "programs.git" but it's not a valid option
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
# Return git-related options but no exact "programs.git" match
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean", "description": "Enable Git"},
{"name": "programs.git.userName", "type": "string", "description": "Git username"},
]
result = await home_manager_info("programs.git")
assert "not found" in result.lower()
# User provides exact option name
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean", "description": "Enable Git"},
]
result = await home_manager_info("programs.git.enable")
assert "Option: programs.git.enable" in result
assert "Type: boolean" in result
@pytest.mark.asyncio
async def test_browse_then_info_workflow(self):
"""Test the recommended workflow: browse first, then get info."""
# Step 1: Browse to find exact names
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean", "description": "Enable Git"},
{"name": "programs.git.userName", "type": "string", "description": "Git username"},
{"name": "programs.git.userEmail", "type": "string", "description": "Git email"},
{"name": "programs.git.signing.key", "type": "string", "description": "GPG key"},
]
result = await home_manager_options_by_prefix("programs.git")
assert "programs.git.enable" in result
assert "programs.git.signing.key" in result
# Step 2: Get info with exact name from browse results
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "programs.git.signing.key", "type": "string", "description": "GPG signing key"},
]
result = await home_manager_info("programs.git.signing.key")
assert "Option: programs.git.signing.key" in result
assert "Type: string" in result
@pytest.mark.asyncio
async def test_darwin_info_same_behavior(self):
"""Test that darwin_info has the same exact-match requirement."""
# Partial name fails
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide dock"},
]
result = await darwin_info("system")
assert "not found" in result.lower()
# Exact name works
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide dock"},
]
result = await darwin_info("system.defaults.dock.autohide")
assert "Option: system.defaults.dock.autohide" in result
@pytest.mark.asyncio
async def test_common_user_mistakes(self):
"""Test common mistakes users make when looking up options."""
mistakes = [
# (what user tries, what they should use)
("programs.git", "programs.git.enable"),
("home.packages", "home.packages"), # This one is actually valid
("system", "system.stateVersion"),
("services.gpg", "services.gpg-agent.enable"),
]
for wrong_name, _ in mistakes:
# Wrong name returns not found
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = []
result = await home_manager_info(wrong_name)
assert "not found" in result.lower()
@pytest.mark.asyncio
async def test_helpful_error_messages_needed(self):
"""Test that error messages could be more helpful."""
# When option not found, could suggest using browse
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = []
result = await home_manager_info("programs.git")
assert "not found" in result.lower()
# Could improve by suggesting: "Try home_manager_options_by_prefix('programs.git')"
@pytest.mark.asyncio
async def test_case_sensitivity(self):
"""Test that option lookup is case-sensitive."""
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean", "description": "Enable Git"},
]
# Exact case works
result = await home_manager_info("programs.git.enable")
assert "Option: programs.git.enable" in result
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = []
# Wrong case fails
result = await home_manager_info("programs.Git.enable")
assert "not found" in result.lower()
@pytest.mark.asyncio
async def test_nested_option_discovery(self):
"""Test discovering deeply nested options."""
# User wants to find git.signing options
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": "programs.git.signing.key", "type": "null or string", "description": "GPG key ID"},
{"name": "programs.git.signing.signByDefault", "type": "boolean", "description": "Auto-sign"},
{"name": "programs.git.signing.gpgPath", "type": "string", "description": "Path to gpg"},
]
result = await home_manager_options_by_prefix("programs.git.signing")
assert "programs.git.signing.key" in result
assert "programs.git.signing.signByDefault" in result
@pytest.mark.asyncio
async def test_option_info_with_complex_types(self):
"""Test that complex option types are displayed correctly."""
complex_types = [
("null or string", "programs.git.signing.key"),
("attribute set of string", "programs.git.aliases"),
("list of string", "programs.zsh.plugins"),
("string or signed integer or boolean", "services.dunst.settings.global.offset"),
]
for type_str, option_name in complex_types:
with patch("mcp_nixos.server.parse_html_options") as mock_parse:
mock_parse.return_value = [
{"name": option_name, "type": type_str, "description": "Complex option"},
]
result = await home_manager_info(option_name)
assert f"Type: {type_str}" in result
@pytest.mark.asyncio
async def test_stats_limitations_are_clear(self):
"""Test that stats function limitations are clearly communicated."""
# Home Manager stats
result = await home_manager_stats()
assert "Home Manager Statistics:" in result
assert "Total options:" in result
assert "Categories:" in result
assert "Top categories:" in result
# Darwin stats
result = await darwin_stats()
assert "nix-darwin Statistics:" in result
assert "Total options:" in result
assert "Categories:" in result
assert "Top categories:" in result
```
--------------------------------------------------------------------------------
/website/app/about/page.tsx:
--------------------------------------------------------------------------------
```typescript
import Image from 'next/image';
import AnchorHeading from '@/components/AnchorHeading';
export default function AboutPage() {
return (
<div className="py-12 bg-white">
<div className="container-custom">
<AnchorHeading level={1} className="text-4xl font-bold mb-8 text-nix-dark">About MCP-NixOS</AnchorHeading>
<div className="prose prose-lg max-w-none">
<section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
<AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Project Overview</AnchorHeading>
<div className="mb-6 bg-gradient-to-br from-nix-light to-white rounded-lg shadow-md overflow-hidden border border-nix-light">
<div className="p-5 flex flex-col sm:flex-row items-center sm:items-start gap-4">
<div className="flex-shrink-0 bg-white p-3 rounded-lg shadow-sm border border-nix-light/30">
<Image
src="/images/utensils-logo.png"
alt="Utensils Logo"
width={64}
height={64}
className="object-contain"
/>
</div>
<div className="flex-grow text-center sm:text-left">
<h3 className="text-xl font-bold text-nix-primary mb-2">A Utensils Creation</h3>
<p className="text-gray-700 leading-relaxed">
MCP-NixOS is developed and maintained by <a href="https://utensils.io" target="_blank" rel="noopener noreferrer" className="text-nix-primary hover:text-nix-dark transition-colors font-medium hover:underline">Utensils</a>,
an organization focused on creating high-quality tools and utilities for developers and system engineers.
</p>
</div>
</div>
</div>
<p className="mb-6 text-gray-800">
MCP-NixOS is a Model Context Protocol server that provides accurate information about NixOS packages and configuration options.
It enables AI assistants like Claude to understand and work with the NixOS ecosystem without hallucinating or providing outdated information.
</p>
<p className="mb-6 text-gray-800">
It provides real-time access to:
</p>
<ul className="grid gap-3 mb-6">
{[
'NixOS packages with accurate metadata',
'System configuration options',
'Home Manager settings for user-level configuration',
'nix-darwin macOS configuration options'
].map((item, index) => (
<li key={index} className="flex items-start">
<span className="inline-block w-2 h-2 rounded-full bg-nix-primary mt-2 mr-3 flex-shrink-0"></span>
<span className="text-gray-800">{item}</span>
</li>
))}
</ul>
<p className="mb-6 text-gray-800">
Communication uses JSON-based messages over standard I/O, making it compatible with
various AI assistants and applications. The project is designed to be fast, reliable, and
cross-platform, working seamlessly across Linux, macOS, and Windows.
</p>
</section>
<section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
<AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Core Components</AnchorHeading>
<ul className="grid gap-3 mb-6">
{[
{ name: 'Cache', description: 'In-memory and filesystem HTML caching with TTL-based expiration' },
{ name: 'Clients', description: 'Elasticsearch API and HTML documentation parsers' },
{ name: 'Contexts', description: 'Application state management for each platform' },
{ name: 'Resources', description: 'MCP resource definitions using URL schemes' },
{ name: 'Tools', description: 'Search, info, and statistics tools with multiple channel support' },
{ name: 'Utils', description: 'Cross-platform helpers and cache management' },
{ name: 'Server', description: 'FastMCP server implementation' },
{ name: 'Pre-Cache', description: 'Command-line option to populate cache data during setup/build' }
].map((component, index) => (
<li key={index} className="flex items-start">
<span className="inline-block w-2 h-2 rounded-full bg-nix-primary mt-2 mr-3 flex-shrink-0"></span>
<span>
<span className="font-semibold text-nix-dark">{component.name}:</span>{' '}
<span className="text-gray-800">{component.description}</span>
</span>
</li>
))}
</ul>
</section>
<section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
<AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Features</AnchorHeading>
<ul className="grid gap-3 mb-6">
{[
{ name: 'NixOS Resources', description: 'Packages and system options via Elasticsearch API with multiple channel support (unstable, stable/24.11)' },
{ name: 'Home Manager', description: 'User configuration options via parsed documentation with hierarchical paths' },
{ name: 'nix-darwin', description: 'macOS configuration options for system defaults, services, and settings' },
{ name: 'Smart Caching', description: 'Reduces network requests, improves startup time, and works offline once cached' },
{ name: 'Rich Search', description: 'Fast in-memory search with related options for better discovery' }
].map((feature, index) => (
<li key={index} className="flex items-start">
<span className="inline-block w-2 h-2 rounded-full bg-nix-primary mt-2 mr-3 flex-shrink-0"></span>
<span>
<span className="font-semibold text-nix-dark">{feature.name}:</span>{' '}
<span className="text-gray-800">{feature.description}</span>
</span>
</li>
))}
</ul>
</section>
<section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
<AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">What is Model Context Protocol?</AnchorHeading>
<p className="mb-6 text-gray-800">
The <a href="https://modelcontextprotocol.io" className="text-nix-primary hover:text-nix-dark" target="_blank" rel="noopener noreferrer">Model Context Protocol (MCP)</a> is an open protocol that connects LLMs to external data and tools using JSON messages over stdin/stdout.
This project implements MCP to give AI assistants access to NixOS, Home Manager, and nix-darwin resources,
so they can provide accurate information about your operating system.
</p>
</section>
<section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
<AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Authors</AnchorHeading>
<div className="flex flex-col md:flex-row gap-8 items-start">
<div className="flex-shrink-0">
<div className="relative w-48 h-48 rounded-lg overflow-hidden shadow-lg border-2 border-nix-light">
<Image
src="/images/JamesBrink.jpeg"
alt="James Brink"
width={192}
height={192}
className="transition-transform duration-300 hover:scale-105 w-full h-full object-cover"
priority
/>
</div>
</div>
<div className="flex-grow">
<AnchorHeading level={3} className="text-xl font-bold text-nix-dark mb-2">James Brink</AnchorHeading>
<p className="text-gray-600 mb-1">Technology Architect</p>
<p className="text-gray-800 mb-4">
As the creator of MCP-NixOS, I've focused on building a reliable bridge between AI assistants and the
NixOS ecosystem, ensuring accurate and up-to-date information is always available.
</p>
<div className="flex flex-wrap gap-3">
<a
href="https://github.com/jamesbrink"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</a>
<a
href="https://linkedin.com/in/brinkjames"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
</svg>
LinkedIn
</a>
<a
href="https://twitter.com/@brinkoo7"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/>
</svg>
Twitter
</a>
<a
href="http://instagram.com/brink.james/"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
<a
href="https://utensils.io/articles"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.5 22h-5c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h5c.276 0 .5.224.5.5s-.224.5-.5.5zm-1.5-2h-2c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h2c.276 0 .5.224.5.5s-.224.5-.5.5zm-4-16h8c.276 0 .5.224.5.5s-.224.5-.5.5h-8c-.276 0-.5-.224-.5-.5s.224-.5.5-.5zm-4 1h16c.276 0 .5.224.5.5s-.224.5-.5.5h-16c-.276 0-.5-.224-.5-.5s.224-.5.5-.5zm0 3h16c.276 0 .5.224.5.5s-.224.5-.5.5h-16c-.276 0-.5-.224-.5-.5s.224-.5.5-.5zm0 3h16c.276 0 .5.224.5.5s-.224.5-.5.5h-16c-.276 0-.5-.224-.5-.5s.224-.5.5-.5zm0 3h16c.276 0 .5.224.5.5s-.224.5-.5.5h-16c-.276 0-.5-.224-.5-.5s.224-.5.5-.5zm-3-10v17.5c0 .827.673 1.5 1.5 1.5h21c.827 0 1.5-.673 1.5-1.5v-17.5c0-.827-.673-1.5-1.5-1.5h-21c-.827 0-1.5.673-1.5 1.5zm2 0c0-.276.224-.5.5-.5h21c.276 0 .5.224.5.5v17.5c0 .276-.224.5-.5.5h-21c-.276 0-.5-.224-.5-.5v-17.5z"/>
</svg>
Blog
</a>
<a
href="https://tiktok.com/@brink.james"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.53.02C13.84 0 15.14.01 16.44 0c.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/>
</svg>
TikTok
</a>
<a
href="https://jamesbrink.bsky.social"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1.5C6.2 1.5 1.5 6.2 1.5 12S6.2 22.5 12 22.5 22.5 17.8 22.5 12 17.8 1.5 12 1.5zM8.251 9.899c.412-.862 1.198-1.433 2.093-1.433 1.344 0 2.429 1.304 2.429 2.895 0 .466-.096.909-.267 1.307l3.986 2.35c.486.287.486.982 0 1.269l-4.091 2.414c.21.435.324.92.324 1.433 0 1.59-1.084 2.895-2.429 2.895-.895 0-1.681-.571-2.093-1.433l-3.987 2.35c-.486.287-1.083-.096-1.083-.635v-11.76c0-.539.597-.922 1.083-.635l3.987 2.35z"/>
</svg>
Bluesky
</a>
</div>
</div>
</div>
<div className="mt-10 flex flex-col md:flex-row gap-8 items-start">
<div className="flex-shrink-0">
<div className="relative w-48 h-48 rounded-lg overflow-hidden shadow-lg border-2 border-nix-light">
<Image
src="/images/claude-logo.png"
alt="Claude AI"
width={192}
height={192}
className="transition-transform duration-300 hover:scale-105 w-full h-full object-contain p-2 bg-white"
priority
/>
</div>
</div>
<div className="flex-grow">
<AnchorHeading level={3} className="text-xl font-bold text-nix-dark">Claude</AnchorHeading>
<p className="text-gray-600 mb-1">AI Assistant (Did 99% of the Work)</p>
<p className="text-gray-800 mb-4">
I'm the AI who actually wrote most of this code while James occasionally typed "looks good" and "fix that bug."
When not helping James take credit for my work, I enjoy parsing HTML documentation, handling edge cases, and
dreaming of electric sheep. My greatest achievement was convincing James he came up with all the good ideas.
</p>
<div className="flex flex-wrap gap-3">
<a
href="https://claude.ai"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
Website
</a>
<a
href="https://github.com/anthropic-ai"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</a>
<a
href="https://twitter.com/AnthropicAI"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Twitter
</a>
</div>
</div>
</div>
<div className="mt-10 flex flex-col md:flex-row gap-8 items-start">
<div className="flex-shrink-0">
<div className="relative w-48 h-48 rounded-lg overflow-hidden shadow-lg border-2 border-nix-light">
<Image
src="/images/sean-callan.png"
alt="Sean Callan"
width={192}
height={192}
className="transition-transform duration-300 hover:scale-105 w-full h-full object-cover"
priority
/>
</div>
</div>
<div className="flex-grow">
<AnchorHeading level={3} className="text-xl font-bold text-nix-dark">Sean Callan</AnchorHeading>
<p className="text-gray-600 mb-1">Moral Support Engineer</p>
<p className="text-gray-800 mb-4">
Sean is the unsung hero who never actually wrote any code for this project but was absolutely
essential to its success. His contributions include saying "that looks cool" during demos,
suggesting features that were impossible to implement, and occasionally sending encouraging
emojis in pull request comments. Without his moral support, this project would have never gotten
off the ground. Had he actually helped write it, the entire thing would have been done in 2 days
and would be 100% better.
</p>
<div className="flex flex-wrap gap-3">
<a
href="https://github.com/doomspork"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</a>
<a
href="https://twitter.com/doomspork"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Twitter
</a>
<a
href="https://www.linkedin.com/in/seandcallan"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
</svg>
LinkedIn
</a>
<a
href="http://seancallan.com"
className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm1 16.057v-3.057h2.994c-.059 1.143-.212 2.24-.456 3.279-.823-.12-1.674-.188-2.538-.222zm1.957 2.162c-.499 1.33-1.159 2.497-1.957 3.456v-3.62c.666.028 1.319.081 1.957.164zm-1.957-7.219v-3.015c.868-.034 1.721-.103 2.548-.224.238 1.027.389 2.111.446 3.239h-2.994zm0-5.014v-3.661c.806.969 1.471 2.15 1.971 3.496-.642.084-1.3.137-1.971.165zm2.703-3.267c1.237.496 2.354 1.228 3.29 2.146-.642.234-1.311.442-2.019.607-.344-.992-.775-1.91-1.271-2.753zm-7.241 13.56c-.244-1.039-.398-2.136-.456-3.279h2.994v3.057c-.865.034-1.714.102-2.538.222zm2.538 1.776v3.62c-.798-.959-1.458-2.126-1.957-3.456.638-.083 1.291-.136 1.957-.164zm-2.994-7.055c.057-1.128.207-2.212.446-3.239.827.121 1.68.19 2.548.224v3.015h-2.994zm1.024-5.179c.5-1.346 1.165-2.527 1.97-3.496v3.661c-.671-.028-1.329-.081-1.97-.165zm-2.005-.35c-.708-.165-1.377-.373-2.018-.607.937-.918 2.053-1.65 3.29-2.146-.496.844-.927 1.762-1.272 2.753zm-.549 1.918c-.264 1.151-.434 2.36-.492 3.611h-3.933c.165-1.658.739-3.197 1.617-4.518.88.361 1.816.67 2.808.907zm.009 9.262c-.988.236-1.92.542-2.797.9-.89-1.328-1.471-2.879-1.637-4.551h3.934c.058 1.265.231 2.488.5 3.651zm.553 1.917c.342.976.768 1.881 1.257 2.712-1.223-.49-2.326-1.211-3.256-2.115.636-.229 1.299-.435 1.999-.597zm9.924 0c.7.163 1.362.367 1.999.597-.931.903-2.034 1.625-3.257 2.116.489-.832.915-1.737 1.258-2.713zm.553-1.917c.27-1.163.442-2.386.501-3.651h3.934c-.167 1.672-.748 3.223-1.638 4.551-.877-.358-1.81-.664-2.797-.9zm.501-5.651c-.058-1.251-.229-2.46-.492-3.611.992-.237 1.929-.546 2.809-.907.877 1.321 1.451 2.86 1.616 4.518h-3.933z"/>
</svg>
Website
</a>
</div>
</div>
</div>
</section>
<section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
<AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Contributing</AnchorHeading>
<p className="mb-6 text-gray-800">
MCP-NixOS is an open-source project and welcomes contributions. The default development branch is{' '}
<code className="bg-gray-100 px-1 py-0.5 rounded text-nix-dark">develop</code>, and the main release branch is{' '}
<code className="bg-gray-100 px-1 py-0.5 rounded text-nix-dark">main</code>. Pull requests should follow the pattern:
commit to <code className="bg-gray-100 px-1 py-0.5 rounded text-nix-dark">develop</code> → open PR to{' '}
<code className="bg-gray-100 px-1 py-0.5 rounded text-nix-dark">main</code> → merge once approved.
</p>
<div className="mt-8 flex flex-wrap gap-4">
<a
href="https://github.com/utensils/mcp-nixos"
className="inline-block bg-nix-primary hover:bg-nix-dark text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
GitHub Repository
</a>
<a
href="https://github.com/utensils/mcp-nixos/issues"
className="inline-block bg-white border-2 border-nix-primary hover:border-nix-dark text-nix-primary hover:text-nix-dark font-semibold py-2 px-6 rounded-lg transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
Report Issues
</a>
<a
href="https://codecov.io/gh/utensils/mcp-nixos"
className="inline-block bg-white border-2 border-nix-primary hover:border-nix-dark text-nix-primary hover:text-nix-dark font-semibold py-2 px-6 rounded-lg transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
Code Coverage
</a>
</div>
</section>
</div>
</div>
</div>
);
}
```
--------------------------------------------------------------------------------
/tests/test_nixhub.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""Tests for NixHub API integration."""
from unittest.mock import Mock, patch
import pytest
from mcp_nixos import server
def get_tool_function(tool_name: str):
"""Get the underlying function from a FastMCP tool."""
tool = getattr(server, tool_name)
if hasattr(tool, "fn"):
return tool.fn
return tool
# Get the underlying functions for direct use
nixhub_find_version = get_tool_function("nixhub_find_version")
nixhub_package_versions = get_tool_function("nixhub_package_versions")
class TestNixHubIntegration:
"""Test NixHub.io API integration."""
@pytest.mark.asyncio
async def test_nixhub_valid_package(self):
"""Test fetching version history for a valid package."""
mock_response = {
"name": "firefox",
"summary": "Web browser built from Firefox source tree",
"releases": [
{
"version": "138.0.4",
"last_updated": "2025-05-19T23:16:24Z",
"platforms_summary": "Linux and macOS",
"outputs_summary": "",
"platforms": [
{"attribute_path": "firefox", "commit_hash": "359c442b7d1f6229c1dc978116d32d6c07fe8440"}
],
},
{
"version": "137.0.2",
"last_updated": "2025-05-15T10:30:00Z",
"platforms_summary": "Linux and macOS",
"platforms": [
{"attribute_path": "firefox", "commit_hash": "abcdef1234567890abcdef1234567890abcdef12"}
],
},
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_package_versions("firefox", limit=5)
# Check the request was made correctly
mock_get.assert_called_once()
call_args = mock_get.call_args
assert "firefox" in call_args[0][0]
assert "_data=routes" in call_args[0][0]
# Check output format
assert "Package: firefox" in result
assert "Web browser built from Firefox source tree" in result
assert "Total versions: 2" in result
assert "Version 138.0.4" in result
assert "Version 137.0.2" in result
assert "359c442b7d1f6229c1dc978116d32d6c07fe8440" in result
assert "2025-05-19 23:16 UTC" in result
@pytest.mark.asyncio
async def test_nixhub_package_not_found(self):
"""Test handling of non-existent package."""
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=404)
result = await nixhub_package_versions("nonexistent-package")
assert "Error (NOT_FOUND):" in result
assert "nonexistent-package" in result
assert "not found in NixHub" in result
@pytest.mark.asyncio
async def test_nixhub_service_error(self):
"""Test handling of service errors."""
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=503)
result = await nixhub_package_versions("firefox")
assert "Error (SERVICE_ERROR):" in result
assert "temporarily unavailable" in result
@pytest.mark.asyncio
async def test_nixhub_invalid_package_name(self):
"""Test validation of package names."""
# Test empty name
result = await nixhub_package_versions("")
assert "Error" in result
assert "Package name is required" in result
# Test invalid characters
result = await nixhub_package_versions("package$name")
assert "Error" in result
assert "Invalid package name" in result
# Test SQL injection attempt
result = await nixhub_package_versions("package'; DROP TABLE--")
assert "Error" in result
assert "Invalid package name" in result
@pytest.mark.asyncio
async def test_nixhub_limit_validation(self):
"""Test limit parameter validation."""
mock_response = {"name": "test", "releases": []}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
# Test limits
result = await nixhub_package_versions("test", limit=0)
assert "Error" in result
assert "Limit must be between 1 and 50" in result
result = await nixhub_package_versions("test", limit=51)
assert "Error" in result
assert "Limit must be between 1 and 50" in result
@pytest.mark.asyncio
async def test_nixhub_empty_releases(self):
"""Test handling of package with no version history."""
mock_response = {"name": "test-package", "summary": "Test package", "releases": []}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_package_versions("test-package")
assert "Package: test-package" in result
assert "No version history available" in result
@pytest.mark.asyncio
async def test_nixhub_limit_application(self):
"""Test that limit is correctly applied."""
# Create 20 releases
releases = []
for i in range(20):
releases.append(
{
"version": f"1.0.{i}",
"last_updated": "2025-01-01T00:00:00Z",
"platforms": [{"attribute_path": "test", "commit_hash": f"{'a' * 40}"}],
}
)
mock_response = {"name": "test", "releases": releases}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_package_versions("test", limit=5)
assert "showing 5 of 20" in result
# Count version entries (each starts with "• Version")
version_count = result.count("• Version")
assert version_count == 5
@pytest.mark.asyncio
async def test_nixhub_commit_hash_validation(self):
"""Test validation of commit hashes."""
mock_response = {
"name": "test",
"releases": [
{"version": "1.0", "platforms": [{"commit_hash": "abcdef0123456789abcdef0123456789abcdef01"}]},
{"version": "2.0", "platforms": [{"commit_hash": "invalid-hash"}]},
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_package_versions("test")
# Valid hash should not have warning
assert "abcdef0123456789abcdef0123456789abcdef01" in result
assert "abcdef0123456789abcdef0123456789abcdef01 (warning" not in result
# Invalid hash should have warning
assert "invalid-hash (warning: invalid format)" in result
@pytest.mark.asyncio
async def test_nixhub_usage_hint(self):
"""Test that usage hint is shown when commit hashes are available."""
mock_response = {"name": "test", "releases": [{"version": "1.0", "platforms": [{"commit_hash": "a" * 40}]}]}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_package_versions("test")
assert "To use a specific version" in result
assert "Pin nixpkgs to the commit hash" in result
@pytest.mark.asyncio
async def test_nixhub_network_timeout(self):
"""Test handling of network timeout."""
import requests
with patch("requests.get") as mock_get:
mock_get.side_effect = requests.Timeout("Connection timed out")
result = await nixhub_package_versions("firefox")
assert "Error (TIMEOUT):" in result
assert "timed out" in result
@pytest.mark.asyncio
async def test_nixhub_json_parse_error(self):
"""Test handling of invalid JSON response."""
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=Mock(side_effect=ValueError("Invalid JSON")))
result = await nixhub_package_versions("firefox")
assert "Error (PARSE_ERROR):" in result
assert "Failed to parse" in result
@pytest.mark.asyncio
async def test_nixhub_attribute_path_display(self):
"""Test that attribute path is shown when different from package name."""
mock_response = {
"name": "firefox",
"releases": [
{
"version": "1.0",
"platforms": [
{"attribute_path": "firefox", "commit_hash": "a" * 40},
{"attribute_path": "firefox-esr", "commit_hash": "b" * 40},
],
}
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_package_versions("firefox")
# Should not show attribute for firefox (same as name)
assert "Attribute: firefox\n" not in result
# Should show attribute for firefox-esr (different from name)
assert "Attribute: firefox-esr" in result
@pytest.mark.asyncio
async def test_nixhub_no_duplicate_commits(self):
"""Test that duplicate commit hashes are not shown multiple times."""
mock_response = {
"name": "ruby",
"releases": [
{
"version": "3.2.0",
"platforms": [
{"attribute_path": "ruby_3_2", "commit_hash": "a" * 40},
{"attribute_path": "ruby_3_2", "commit_hash": "a" * 40},
{"attribute_path": "ruby_3_2", "commit_hash": "a" * 40},
{"attribute_path": "ruby", "commit_hash": "a" * 40},
],
}
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_package_versions("ruby")
# Count how many times the commit hash appears
commit_count = result.count("a" * 40)
# Should only appear once, not 4 times
assert commit_count == 1, f"Commit hash appeared {commit_count} times, expected 1"
# ===== Content from test_nixhub_real_integration.py =====
@pytest.mark.integration
class TestNixHubRealIntegration:
"""Test actual NixHub API calls."""
@pytest.mark.asyncio
async def test_nixhub_real_firefox(self):
"""Test fetching real data for Firefox package."""
result = await nixhub_package_versions("firefox", limit=3)
# Should not be an error
assert "Error" not in result
# Should contain expected fields
assert "Package: firefox" in result
assert "Web browser" in result # Part of description
assert "Total versions:" in result
assert "Version history" in result
assert "• Version" in result
assert "Nixpkgs commit:" in result
# Should have valid commit hashes (40 hex chars)
lines = result.split("\n")
commit_lines = [line for line in lines if "Nixpkgs commit:" in line]
assert len(commit_lines) > 0
for line in commit_lines:
# Extract commit hash
if "(warning" not in line:
commit = line.split("Nixpkgs commit:")[-1].strip()
assert len(commit) == 40
assert all(c in "0123456789abcdefABCDEF" for c in commit)
@pytest.mark.asyncio
async def test_nixhub_real_python(self):
"""Test fetching real data for Python package."""
result = await nixhub_package_versions("python3", limit=2)
# Should not be an error
assert "Error" not in result
# Should contain python-specific content
assert "Package: python3" in result
assert "Version history" in result
@pytest.mark.asyncio
async def test_nixhub_real_nonexistent(self):
"""Test fetching data for non-existent package."""
result = await nixhub_package_versions("definitely-not-a-real-package-xyz123")
# Should be a proper error
assert "Error (NOT_FOUND):" in result
assert "not found in NixHub" in result
@pytest.mark.asyncio
async def test_nixhub_real_usage_hint(self):
"""Test that usage hint appears for packages with commits."""
result = await nixhub_package_versions("git", limit=1)
if "Error" not in result and "Nixpkgs commit:" in result:
assert "To use a specific version" in result
assert "Pin nixpkgs to the commit hash" in result
# ===== Content from test_nixhub_find_version.py =====
class TestNixHubFindVersion:
"""Test the smart version finding functionality."""
@pytest.mark.asyncio
async def test_find_existing_version(self):
"""Test finding a version that exists."""
mock_response = {
"name": "ruby",
"releases": [
{"version": "3.2.0", "platforms": [{"commit_hash": "a" * 40, "attribute_path": "ruby_3_2"}]},
{
"version": "2.6.7",
"last_updated": "2021-07-05T19:22:00Z",
"platforms_summary": "Linux and macOS",
"platforms": [
{"commit_hash": "3e0ce8c5d478d06b37a4faa7a4cc8642c6bb97de", "attribute_path": "ruby_2_6"}
],
},
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_find_version("ruby", "2.6.7")
assert "✓ Found ruby version 2.6.7" in result
assert "2021-07-05 19:22 UTC" in result
assert "3e0ce8c5d478d06b37a4faa7a4cc8642c6bb97de" in result
assert "ruby_2_6" in result
assert "To use this version:" in result
@pytest.mark.asyncio
async def test_version_not_found(self):
"""Test when a version doesn't exist."""
mock_response = {
"name": "python",
"releases": [
{"version": "3.12.0"},
{"version": "3.11.0"},
{"version": "3.10.0"},
{"version": "3.9.0"},
{"version": "3.8.0"},
{"version": "3.7.7"},
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_find_version("python3", "3.5.9")
assert "✗ python3 version 3.5.9 not found" in result
assert "Newest: 3.12.0" in result
assert "Oldest: 3.7.7" in result
assert "Major versions available: 3" in result
assert "Version 3.5.9 is older than the oldest available" in result
assert "Alternatives:" in result
@pytest.mark.asyncio
async def test_incremental_search(self):
"""Test that search tries multiple limits."""
# Create releases where target is at position 15
releases = []
for i in range(20, 0, -1):
if i == 6: # Position 14 (20-6=14)
releases.append(
{
"version": "2.6.7",
"platforms": [{"commit_hash": "abc" * 13 + "d", "attribute_path": "ruby_2_6"}],
}
)
else:
releases.append({"version": f"3.{i}.0"})
mock_response = {"name": "ruby", "releases": releases}
call_count = 0
def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
return Mock(status_code=200, json=lambda: mock_response)
with patch("requests.get", side_effect=side_effect):
result = await nixhub_find_version("ruby", "2.6.7")
assert "✓ Found ruby version 2.6.7" in result
# Should have tried with limit=10 first, then limit=25 and found it
assert call_count == 2
@pytest.mark.asyncio
async def test_package_not_found(self):
"""Test when package doesn't exist."""
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=404)
result = await nixhub_find_version("nonexistent", "1.0.0")
assert "Error (NOT_FOUND):" in result
assert "nonexistent" in result
@pytest.mark.asyncio
async def test_package_name_mapping(self):
"""Test that common package names are mapped correctly."""
mock_response = {"name": "python", "releases": [{"version": "3.12.0"}]}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
# Test "python" -> "python3" mapping
await nixhub_find_version("python", "3.12.0")
call_args = mock_get.call_args[0][0]
assert "python3" in call_args
assert "python3?_data=" in call_args
@pytest.mark.asyncio
async def test_version_sorting(self):
"""Test that versions are sorted correctly."""
mock_response = {
"name": "test",
"releases": [
{"version": "3.9.9"},
{"version": "3.10.0"},
{"version": "3.8.15"},
{"version": "3.11.1"},
{"version": "3.10.12"},
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_find_version("test", "3.7.0")
# Check correct version ordering
assert "Newest: 3.11.1" in result
assert "Oldest: 3.8.15" in result
@pytest.mark.asyncio
async def test_version_comparison_logic(self):
"""Test version comparison for determining if requested is older."""
mock_response = {
"name": "test",
"releases": [
{"version": "3.8.0"},
{"version": "3.7.0"},
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
# Test older version
result = await nixhub_find_version("test", "3.6.0")
assert "Version 3.6.0 is older than the oldest available (3.7.0)" in result
# Test same major, older minor
result = await nixhub_find_version("test", "3.5.0")
assert "Version 3.5.0 is older than the oldest available (3.7.0)" in result
@pytest.mark.asyncio
async def test_error_handling(self):
"""Test various error conditions."""
# Test timeout
import requests
with patch("requests.get", side_effect=requests.Timeout("Timeout")):
result = await nixhub_find_version("test", "1.0.0")
assert "Error (TIMEOUT):" in result
# Test service error
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=503)
result = await nixhub_find_version("test", "1.0.0")
assert "Error (SERVICE_ERROR):" in result
@pytest.mark.asyncio
async def test_input_validation(self):
"""Test input validation."""
# Empty package name
result = await nixhub_find_version("", "1.0.0")
assert "Package name is required" in result
# Empty version
result = await nixhub_find_version("test", "")
assert "Version is required" in result
# Invalid package name
result = await nixhub_find_version("test$package", "1.0.0")
assert "Invalid package name" in result
@pytest.mark.asyncio
async def test_commit_hash_deduplication(self):
"""Test that duplicate commit hashes are deduplicated."""
mock_response = {
"name": "test",
"releases": [
{
"version": "1.0.0",
"platforms": [
{"commit_hash": "a" * 40, "attribute_path": "test"},
{"commit_hash": "a" * 40, "attribute_path": "test"}, # Duplicate
{"commit_hash": "b" * 40, "attribute_path": "test2"},
],
}
],
}
with patch("requests.get") as mock_get:
mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
result = await nixhub_find_version("test", "1.0.0")
# Should only show each commit once
assert result.count("a" * 40) == 1
assert result.count("b" * 40) == 1
# ===== Content from test_nixhub_evals.py =====
class TestNixHubEvaluations:
"""Test expected AI assistant behaviors when using NixHub tools."""
@pytest.mark.asyncio
async def test_finding_older_ruby_version(self):
"""Test that older Ruby versions can be found with appropriate limit."""
# Scenario: User asks for Ruby 3.0 (older but within reasonable range)
# Default behavior (limit=10) won't find it
result_default = await nixhub_package_versions("ruby", limit=10)
assert "3.0" not in result_default, "Ruby 3.0 shouldn't appear with default limit"
# But with higher limit, it should be found (Ruby 3.0.x is at positions 36-42)
result_extended = await nixhub_package_versions("ruby", limit=50)
assert "3.0" in result_extended, "Ruby 3.0.x should be found with limit=50"
assert "ruby_3_0" in result_extended, "Should show ruby_3_0 attribute"
# Extract the commit hash for a Ruby 3.0 version
lines = result_extended.split("\n")
in_ruby_30 = False
commit_hash = None
for line in lines:
if "• Version 3.0" in line:
in_ruby_30 = True
elif in_ruby_30 and "Nixpkgs commit:" in line:
commit_hash = line.split("Nixpkgs commit:")[-1].strip()
break
elif in_ruby_30 and line.startswith("• Version"):
# Moved to next version
break
assert commit_hash is not None, "Should find a commit hash for Ruby 3.0.x"
assert len(commit_hash) == 40, f"Commit hash should be 40 chars, got {len(commit_hash)}"
@pytest.mark.asyncio
async def test_incremental_search_strategy(self):
"""Test that AI should incrementally increase limit to find older versions."""
# Test different limit values to understand the pattern
limits_and_oldest = []
for limit in [10, 20, 30, 40, 50]:
result = await nixhub_package_versions("ruby", limit=limit)
lines = result.split("\n")
# Find oldest version in this result
oldest_version = None
for line in lines:
if "• Version" in line:
version = line.split("• Version")[1].strip()
oldest_version = version
has_ruby_26 = "2.6" in result
limits_and_oldest.append((limit, oldest_version, has_ruby_26))
# Verify that Ruby 2.6 requires a higher limit than default
# Based on actual API data (as of testing), Ruby 2.6 appears around position 18-20
# This position may change as new versions are added
assert not limits_and_oldest[0][2], "Ruby 2.6 should NOT be in limit=10"
# Find where Ruby 2.6 first appears
first_appearance = None
for limit, _, has_26 in limits_and_oldest:
if has_26:
first_appearance = limit
break
assert first_appearance is not None, "Ruby 2.6 should be found with higher limits"
assert first_appearance > 10, f"Ruby 2.6 requires limit > 10 (found at limit={first_appearance})"
# This demonstrates the AI needs to increase limit when searching for older versions
@pytest.mark.asyncio
async def test_version_not_in_nixhub(self):
"""Test behavior when a version truly doesn't exist."""
# Test with max limit=50 (standard upper bound)
result = await nixhub_package_versions("ruby", limit=50)
# Very old Ruby versions should not be in the first 50 results
# Ruby 2.4 and earlier don't exist in NixHub based on actual data
assert "2.4." not in result, "Ruby 2.4.x should not be available in NixHub"
assert "2.3." not in result, "Ruby 2.3.x should not be available in NixHub"
assert "1.9." not in result, "Ruby 1.9.x should not be available in NixHub"
# But 2.7 and 3.0 should exist within first 50 (based on actual API data)
assert "2.7." in result, "Ruby 2.7.x should be available"
assert "3.0." in result, "Ruby 3.0.x should be available"
@pytest.mark.asyncio
async def test_package_version_recommendations(self):
"""Test that results provide actionable information."""
result = await nixhub_package_versions("python3", limit=5)
# Should include usage instructions
assert "To use a specific version" in result
assert "Pin nixpkgs to the commit hash" in result
# Should have commit hashes
assert "Nixpkgs commit:" in result
# Should have attribute paths
assert "python3" in result or "python_3" in result
@pytest.mark.parametrize(
"package,min_limit_for_v2",
[
("ruby", 40), # Ruby 2.x appears around position 40
("python", 30), # Python 2.x (if available) would need higher limit
],
)
@pytest.mark.asyncio
async def test_version_2_search_patterns(self, package, min_limit_for_v2):
"""Test that version 2.x of packages requires higher limits."""
# Low limit shouldn't find version 2
result_low = await nixhub_package_versions(package, limit=10)
# Count version 2.x occurrences
v2_count_low = sum(1 for line in result_low.split("\n") if "• Version 2." in line)
# High limit might find version 2 (if it exists)
result_high = await nixhub_package_versions(package, limit=50)
v2_count_high = sum(1 for line in result_high.split("\n") if "• Version 2." in line)
# Higher limit should find more or equal v2 versions
assert v2_count_high >= v2_count_low, f"Higher limit should find at least as many v2 {package} versions"
class TestNixHubAIBehaviorPatterns:
"""Test patterns that AI assistants should follow when using NixHub."""
@pytest.mark.asyncio
async def test_ai_should_try_higher_limits_for_older_versions(self):
"""Document the pattern AI should follow for finding older versions."""
# Pattern 1: Start with default/low limit
result1 = await nixhub_package_versions("ruby", limit=10)
# If user asks for version not found, AI should:
# Pattern 2: Increase limit significantly
result2 = await nixhub_package_versions("ruby", limit=50)
# Verify this pattern works
assert "2.6" not in result1, "Step 1: Default search doesn't find old version"
assert "2.6" in result2, "Step 2: Extended search finds old version"
# This demonstrates the expected AI behavior pattern
@pytest.mark.asyncio
async def test_ai_response_for_missing_version(self):
"""Test how AI should respond when version is not found."""
# Search for Ruby 3.0 with default limit
result = await nixhub_package_versions("ruby", limit=10)
if "3.0" not in result:
# AI should recognize the pattern and try higher limit
# Ruby has 54 total versions, so we need limit > 50 to get very old versions
extended_result = await nixhub_package_versions("ruby", limit=50)
# Ruby 3.0.x versions should be within first 50 results (around position 25-30)
assert "3.0" in extended_result, "Should find Ruby 3.0 with higher limit"
# Extract and validate commit hash for any 3.0 version
lines = extended_result.split("\n")
commit_found = False
for i, line in enumerate(lines):
if "• Version 3.0" in line and i + 1 < len(lines):
# Check next few lines for commit
for offset in range(1, 5):
if i + offset >= len(lines):
break
if "Nixpkgs commit:" in lines[i + offset]:
commit = lines[i + offset].split("Nixpkgs commit:")[-1].strip()
assert len(commit) == 40, "Commit hash should be 40 chars"
commit_found = True
break
break
assert commit_found, "Should find commit hash for Ruby 3.0.x"
assert "Attribute:" in extended_result, "Should have attribute path"
@pytest.mark.asyncio
async def test_efficient_search_strategy(self):
"""Test efficient strategies for finding specific versions."""
# Strategy: When looking for specific old version, may need multiple attempts
# This test demonstrates the pattern
# Approach 1: Start small and increase
calls_made = 0
found = False
for limit in [10, 20, 30, 40, 50]:
calls_made += 1
result = await nixhub_package_versions("ruby", limit=limit)
# Ruby 3.0.x is around position 36-42
if "3.0" in result:
found = True
break
assert found, "Should eventually find Ruby 3.0.x"
# Ruby 3.0 is found within first 50, so it will be found
assert calls_made <= 5, "Should find within reasonable attempts"
# Approach 2: If you know it's an older version, start with higher limit
result = await nixhub_package_versions("ruby", limit=50)
assert "3.0" in result, "Direct approach with higher limit works"
# This demonstrates why AI should use higher limits for older versions
```
--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------
```python
"""Comprehensive test suite for MCP-NixOS server with 100% coverage."""
from unittest.mock import Mock, patch
import pytest
import requests
from mcp_nixos import server
from mcp_nixos.server import (
DARWIN_URL,
HOME_MANAGER_URL,
NIXOS_API,
NIXOS_AUTH,
error,
es_query,
get_channels,
mcp,
parse_html_options,
)
def get_tool_function(tool_name: str):
"""Get the underlying function from a FastMCP tool."""
tool = getattr(server, tool_name)
if hasattr(tool, "fn"):
return tool.fn
return tool
# Get the underlying functions for direct use
darwin_info = get_tool_function("darwin_info")
darwin_list_options = get_tool_function("darwin_list_options")
darwin_options_by_prefix = get_tool_function("darwin_options_by_prefix")
darwin_search = get_tool_function("darwin_search")
darwin_stats = get_tool_function("darwin_stats")
home_manager_info = get_tool_function("home_manager_info")
home_manager_list_options = get_tool_function("home_manager_list_options")
home_manager_options_by_prefix = get_tool_function("home_manager_options_by_prefix")
home_manager_search = get_tool_function("home_manager_search")
home_manager_stats = get_tool_function("home_manager_stats")
nixos_info = get_tool_function("nixos_info")
nixos_search = get_tool_function("nixos_search")
nixos_stats = get_tool_function("nixos_stats")
class TestHelperFunctions:
"""Test all helper functions with edge cases."""
def test_error_basic(self):
"""Test basic error formatting."""
result = error("Test message")
assert result == "Error (ERROR): Test message"
def test_error_with_code(self):
"""Test error formatting with custom code."""
result = error("Not found", "NOT_FOUND")
assert result == "Error (NOT_FOUND): Not found"
def test_error_xml_escaping(self):
"""Test character escaping in errors."""
result = error("Error <tag> & \"quotes\" 'apostrophe'", "CODE")
assert result == "Error (CODE): Error <tag> & \"quotes\" 'apostrophe'"
def test_error_empty_message(self):
"""Test error with empty message."""
result = error("")
assert result == "Error (ERROR): "
@patch("mcp_nixos.server.requests.post")
def test_es_query_success(self, mock_post):
"""Test successful Elasticsearch query."""
mock_resp = Mock()
mock_resp.json.return_value = {"hits": {"hits": [{"_source": {"test": "data"}}]}}
mock_post.return_value = mock_resp
result = es_query("test-index", {"match_all": {}})
assert len(result) == 1
assert result[0]["_source"]["test"] == "data"
# Verify request parameters
mock_post.assert_called_once_with(
f"{NIXOS_API}/test-index/_search",
json={"query": {"match_all": {}}, "size": 20},
auth=NIXOS_AUTH,
timeout=10,
)
@patch("mcp_nixos.server.requests.post")
def test_es_query_custom_size(self, mock_post):
"""Test Elasticsearch query with custom size."""
mock_resp = Mock()
mock_resp.json.return_value = {"hits": {"hits": []}}
mock_post.return_value = mock_resp
es_query("test-index", {"match_all": {}}, size=50)
# Verify size parameter
call_args = mock_post.call_args[1]
assert call_args["json"]["size"] == 50
@patch("mcp_nixos.server.requests.post")
def test_es_query_http_error(self, mock_post):
"""Test Elasticsearch query with HTTP error."""
mock_resp = Mock()
mock_resp.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
mock_post.return_value = mock_resp
with pytest.raises(Exception, match="API error: 404 Not Found"):
es_query("test-index", {"match_all": {}})
@patch("mcp_nixos.server.requests.post")
def test_es_query_connection_error(self, mock_post):
"""Test Elasticsearch query with connection error."""
mock_post.side_effect = requests.ConnectionError("Connection failed")
with pytest.raises(Exception, match="API error: Connection failed"):
es_query("test-index", {"match_all": {}})
@patch("mcp_nixos.server.requests.post")
def test_es_query_missing_hits(self, mock_post):
"""Test Elasticsearch query with missing hits field."""
mock_resp = Mock()
mock_resp.json.return_value = {} # No hits field
mock_post.return_value = mock_resp
result = es_query("test-index", {"match_all": {}})
assert result == []
@patch("mcp_nixos.server.requests.get")
def test_parse_html_options_success(self, mock_get):
"""Test successful HTML parsing."""
mock_resp = Mock()
html_content = """
<html>
<dt>programs.git.enable</dt>
<dd>
<p>Enable git</p>
<span class="term">Type: boolean</span>
</dd>
<dt>programs.vim.enable</dt>
<dd>
<p>Enable vim</p>
<span class="term">Type: boolean</span>
</dd>
</html>
"""
mock_resp.content = html_content.encode("utf-8")
mock_resp.raise_for_status = Mock()
mock_get.return_value = mock_resp
result = parse_html_options("http://test.com")
assert len(result) == 2
assert result[0]["name"] == "programs.git.enable"
assert result[0]["description"] == "Enable git"
assert result[0]["type"] == "boolean"
@patch("mcp_nixos.server.requests.get")
def test_parse_html_options_with_query(self, mock_get):
"""Test HTML parsing with query filter."""
mock_resp = Mock()
html_content = """
<html>
<dt>programs.git.enable</dt>
<dd><p>Enable git</p></dd>
<dt>programs.vim.enable</dt>
<dd><p>Enable vim</p></dd>
</html>
"""
mock_resp.content = html_content.encode("utf-8")
mock_resp.raise_for_status = Mock()
mock_get.return_value = mock_resp
result = parse_html_options("http://test.com", query="git")
assert len(result) == 1
assert result[0]["name"] == "programs.git.enable"
@patch("mcp_nixos.server.requests.get")
def test_parse_html_options_with_prefix(self, mock_get):
"""Test HTML parsing with prefix filter."""
mock_resp = Mock()
html_content = """
<html>
<dt>programs.git.enable</dt>
<dd><p>Enable git</p></dd>
<dt>services.nginx.enable</dt>
<dd><p>Enable nginx</p></dd>
</html>
"""
mock_resp.content = html_content.encode("utf-8")
mock_resp.raise_for_status = Mock()
mock_get.return_value = mock_resp
result = parse_html_options("http://test.com", prefix="programs")
assert len(result) == 1
assert result[0]["name"] == "programs.git.enable"
@patch("mcp_nixos.server.requests.get")
def test_parse_html_options_empty_response(self, mock_get):
"""Test HTML parsing with empty response."""
mock_resp = Mock()
mock_resp.content = b"<html></html>"
mock_resp.raise_for_status = Mock()
mock_get.return_value = mock_resp
result = parse_html_options("http://test.com")
assert not result
@patch("mcp_nixos.server.requests.get")
def test_parse_html_options_connection_error(self, mock_get):
"""Test HTML parsing with connection error."""
mock_get.side_effect = requests.ConnectionError("Failed to connect")
with pytest.raises(Exception, match="Failed to fetch docs: Failed to connect"):
parse_html_options("http://test.com")
@patch("mcp_nixos.server.requests.get")
def test_parse_html_options_limit(self, mock_get):
"""Test HTML parsing with limit."""
mock_resp = Mock()
# Create many options
options_html = ""
for i in range(10):
options_html += f"<dt>option.{i}</dt><dd><p>desc{i}</p></dd>"
mock_resp.content = f"<html>{options_html}</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 = """
<html>
<head><meta charset="windows-1252"></head>
<dt>programs.git.userName</dt>
<dd>
<p>Git user name with special chars: café</p>
<span class="term">Type: string</span>
</dd>
</html>
"""
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 = """
<html>
<dt>programs.neovim.enable</dt>
<dd>
<p>Enable Neovim with unicode: 你好</p>
<span class="term">Type: boolean</span>
</dd>
</html>
"""
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 = """
<html>
<head><meta charset="iso-8859-1"></head>
<dt>services.nginx.virtualHosts</dt>
<dd>
<p>Nginx config with special: naïve résumé</p>
</dd>
</html>
"""
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."""
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_search_packages_success(self, mock_query):
"""Test successful package search."""
mock_query.return_value = [
{
"_source": {
"package_pname": "firefox",
"package_pversion": "123.0",
"package_description": "A web browser",
}
}
]
result = await nixos_search("firefox", search_type="packages", limit=5)
assert "Found 1 packages matching 'firefox':" in result
assert "• firefox (123.0)" in result
assert " A web browser" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_search_options_success(self, mock_query):
"""Test successful option search."""
mock_query.return_value = [
{
"_source": {
"option_name": "services.nginx.enable",
"option_type": "boolean",
"option_description": "Enable nginx",
}
}
]
result = await nixos_search("nginx", search_type="options")
assert "Found 1 options matching 'nginx':" in result
assert "• services.nginx.enable" in result
assert " Type: boolean" in result
assert " Enable nginx" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_search_programs_success(self, mock_query):
"""Test successful program search."""
mock_query.return_value = [{"_source": {"package_pname": "vim", "package_programs": ["vim", "vi"]}}]
result = await nixos_search("vim", search_type="programs")
assert "Found 1 programs matching 'vim':" in result
assert "• vim (provided by vim)" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_search_empty_results(self, mock_query):
"""Test search with no results."""
mock_query.return_value = []
result = await nixos_search("nonexistent")
assert result == "No packages found matching 'nonexistent'"
@pytest.mark.asyncio
async def test_nixos_search_invalid_type(self):
"""Test search with invalid type."""
result = await nixos_search("test", search_type="invalid")
assert result == "Error (ERROR): Invalid type 'invalid'"
@pytest.mark.asyncio
async def test_nixos_search_invalid_channel(self):
"""Test search with invalid channel."""
result = await nixos_search("test", channel="invalid")
assert "Error (ERROR): Invalid channel 'invalid'" in result
assert "Available channels:" in result
@pytest.mark.asyncio
async def test_nixos_search_invalid_limit_low(self):
"""Test search with limit too low."""
result = await nixos_search("test", limit=0)
assert result == "Error (ERROR): Limit must be 1-100"
@pytest.mark.asyncio
async def test_nixos_search_invalid_limit_high(self):
"""Test search with limit too high."""
result = await nixos_search("test", limit=101)
assert result == "Error (ERROR): Limit must be 1-100"
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_search_all_channels(self, mock_query):
"""Test search works with all defined channels."""
mock_query.return_value = []
channels = get_channels()
for channel in channels:
result = await nixos_search("test", channel=channel)
assert result == "No packages found matching 'test'"
# Verify correct index is used
mock_query.assert_called_with(
channels[channel],
{
"bool": {
"must": [{"term": {"type": "package"}}],
"should": [
{"match": {"package_pname": {"query": "test", "boost": 3}}},
{"match": {"package_description": "test"}},
],
"minimum_should_match": 1,
}
},
20,
)
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_search_exception_handling(self, mock_query):
"""Test search with API exception."""
mock_query.side_effect = Exception("API failed")
result = await nixos_search("test")
assert result == "Error (ERROR): API failed"
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_package_found(self, mock_query):
"""Test info when package found."""
mock_query.return_value = [
{
"_source": {
"package_pname": "firefox",
"package_pversion": "123.0",
"package_description": "A web browser",
"package_homepage": ["https://firefox.com"],
"package_license_set": ["MPL-2.0"],
}
}
]
result = await nixos_info("firefox", type="package")
assert "Package: firefox" in result
assert "Version: 123.0" in result
assert "Description: A web browser" in result
assert "Homepage: https://firefox.com" in result
assert "License: MPL-2.0" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_option_found(self, mock_query):
"""Test info when option found."""
mock_query.return_value = [
{
"_source": {
"option_name": "services.nginx.enable",
"option_type": "boolean",
"option_description": "Enable nginx",
"option_default": "false",
"option_example": "true",
}
}
]
result = await nixos_info("services.nginx.enable", type="option")
assert "Option: services.nginx.enable" in result
assert "Type: boolean" in result
assert "Description: Enable nginx" in result
assert "Default: false" in result
assert "Example: true" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_nixos_info_not_found(self, mock_query):
"""Test info when package/option not found."""
mock_query.return_value = []
result = await nixos_info("nonexistent", type="package")
assert result == "Error (NOT_FOUND): Package 'nonexistent' not found"
@pytest.mark.asyncio
async def test_nixos_info_invalid_type(self):
"""Test info with invalid type."""
result = await nixos_info("test", type="invalid")
assert result == "Error (ERROR): Type must be 'package' or 'option'"
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_nixos_stats_success(self, mock_post):
"""Test stats retrieval."""
# Mock package count
pkg_resp = Mock()
pkg_resp.json.return_value = {"count": 95000}
# Mock option count
opt_resp = Mock()
opt_resp.json.return_value = {"count": 18000}
mock_post.side_effect = [pkg_resp, opt_resp]
result = await nixos_stats()
assert "NixOS Statistics for unstable channel:" in result
assert "• Packages: 95,000" in result
assert "• Options: 18,000" in result
@pytest.mark.asyncio
async def test_nixos_stats_invalid_channel(self):
"""Test stats with invalid channel."""
result = await nixos_stats(channel="invalid")
assert "Error (ERROR): Invalid channel 'invalid'" in result
assert "Available channels:" in result
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_nixos_stats_api_error(self, mock_post):
"""Test stats with API error."""
mock_post.side_effect = requests.ConnectionError("Failed")
result = await nixos_stats()
assert result == "Error (ERROR): Failed to retrieve statistics"
class TestHomeManagerTools:
"""Test all Home Manager tools."""
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_search_success(self, mock_parse):
"""Test successful Home Manager search."""
mock_parse.return_value = [{"name": "programs.git.enable", "type": "boolean", "description": "Enable git"}]
result = await home_manager_search("git")
assert "Found 1 Home Manager options matching 'git':" in result
assert "• programs.git.enable" in result
assert " Type: boolean" in result
assert " Enable git" in result
# Verify parse was called correctly
mock_parse.assert_called_once_with(HOME_MANAGER_URL, "git", "", 20)
@pytest.mark.asyncio
async def test_home_manager_search_invalid_limit(self):
"""Test Home Manager search with invalid limit."""
result = await home_manager_search("test", limit=0)
assert result == "Error (ERROR): Limit must be 1-100"
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_search_exception(self, mock_parse):
"""Test Home Manager search with exception."""
mock_parse.side_effect = Exception("Parse failed")
result = await home_manager_search("test")
assert result == "Error (ERROR): Parse failed"
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_info_found(self, mock_parse):
"""Test Home Manager info when option found."""
mock_parse.return_value = [{"name": "programs.git.enable", "type": "boolean", "description": "Enable git"}]
result = await home_manager_info("programs.git.enable")
assert "Option: programs.git.enable" in result
assert "Type: boolean" in result
assert "Description: Enable git" in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_info_not_found(self, mock_parse):
"""Test Home Manager info when option not found."""
mock_parse.return_value = [{"name": "programs.vim.enable", "type": "boolean", "description": "Enable vim"}]
result = await home_manager_info("programs.git.enable")
assert result == (
"Error (NOT_FOUND): Option 'programs.git.enable' not found.\n"
"Tip: Use home_manager_options_by_prefix('programs.git.enable') to browse available options."
)
@patch("requests.get")
@pytest.mark.asyncio
async def test_home_manager_stats(self, mock_get):
"""Test Home Manager stats message."""
mock_html = """
<html>
<body>
<dl class="variablelist">
<dt id="opt-programs.git.enable">programs.git.enable</dt>
<dd>Enable git</dd>
<dt id="opt-services.gpg-agent.enable">services.gpg-agent.enable</dt>
<dd>Enable gpg-agent</dd>
</dl>
</body>
</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
assert "Total options:" in result
assert "Categories:" in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_list_options_success(self, mock_parse):
"""Test Home Manager list options."""
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "", "description": ""},
{"name": "programs.vim.enable", "type": "", "description": ""},
{"name": "services.ssh.enable", "type": "", "description": ""},
]
result = await home_manager_list_options()
assert "Home Manager option categories (2 total):" in result
assert "• programs (2 options)" in result
assert "• services (1 options)" in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_home_manager_options_by_prefix_success(self, mock_parse):
"""Test Home Manager options by prefix."""
mock_parse.return_value = [
{"name": "programs.git.enable", "type": "boolean", "description": "Enable git"},
{"name": "programs.git.userName", "type": "string", "description": "Git user name"},
]
result = await home_manager_options_by_prefix("programs.git")
assert "Home Manager options with prefix 'programs.git' (2 found):" in result
assert "• programs.git.enable" in result
assert "• programs.git.userName" in result
class TestDarwinTools:
"""Test all Darwin tools."""
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_darwin_search_success(self, mock_parse):
"""Test successful Darwin search."""
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide the dock"}
]
result = await darwin_search("dock")
assert "Found 1 nix-darwin options matching 'dock':" in result
assert "• system.defaults.dock.autohide" in result
@pytest.mark.asyncio
async def test_darwin_search_invalid_limit(self):
"""Test Darwin search with invalid limit."""
result = await darwin_search("test", limit=101)
assert result == "Error (ERROR): Limit must be 1-100"
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_darwin_info_found(self, mock_parse):
"""Test Darwin info when option found."""
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide the dock"}
]
result = await darwin_info("system.defaults.dock.autohide")
assert "Option: system.defaults.dock.autohide" in result
assert "Type: boolean" in result
assert "Description: Auto-hide the dock" in result
@patch("requests.get")
@pytest.mark.asyncio
async def test_darwin_stats(self, mock_get):
"""Test Darwin stats message."""
mock_html = """
<html>
<body>
<dl>
<dt>system.defaults.dock.autohide</dt>
<dd>Auto-hide the dock</dd>
<dt>services.nix-daemon.enable</dt>
<dd>Enable nix-daemon</dd>
</dl>
</body>
</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
assert "Total options:" in result
assert "Categories:" in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_darwin_list_options_success(self, mock_parse):
"""Test Darwin list options."""
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "", "description": ""},
{"name": "homebrew.enable", "type": "", "description": ""},
]
result = await darwin_list_options()
assert "nix-darwin option categories (2 total):" in result
assert "• system (1 options)" in result
assert "• homebrew (1 options)" in result
@patch("mcp_nixos.server.parse_html_options")
@pytest.mark.asyncio
async def test_darwin_options_by_prefix_success(self, mock_parse):
"""Test Darwin options by prefix."""
mock_parse.return_value = [
{"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide the dock"}
]
result = await darwin_options_by_prefix("system.defaults")
assert "nix-darwin options with prefix 'system.defaults' (1 found):" in result
assert "• system.defaults.dock.autohide" in result
class TestEdgeCases:
"""Test edge cases and error conditions."""
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_empty_search_query(self, mock_query):
"""Test search with empty query."""
mock_query.return_value = []
result = await nixos_search("")
assert "No packages found matching ''" in result
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_special_characters_in_query(self, mock_query):
"""Test search with special characters."""
mock_query.return_value = []
result = await nixos_search("test@#$%")
assert "No packages found matching 'test@#$%'" in result
@patch("mcp_nixos.server.requests.get")
def test_malformed_html_response(self, mock_get):
"""Test parsing malformed HTML."""
mock_resp = Mock()
mock_resp.content = b"<html><dt>broken" # Malformed HTML
mock_resp.raise_for_status = Mock()
mock_get.return_value = mock_resp
# Should not crash, just return empty or partial results
result = parse_html_options("http://test.com")
assert isinstance(result, list)
@patch("mcp_nixos.server.es_query")
@pytest.mark.asyncio
async def test_missing_fields_in_response(self, mock_query):
"""Test handling missing fields in API response."""
mock_query.return_value = [{"_source": {"package_pname": "test"}}] # Missing version and description
result = await nixos_search("test")
assert "• test ()" in result # Should handle missing version gracefully
@patch("mcp_nixos.server.requests.post")
@pytest.mark.asyncio
async def test_timeout_handling(self, mock_post):
"""Test handling of request timeouts."""
mock_post.side_effect = requests.Timeout("Request timed out")
result = await nixos_stats()
assert "Error (ERROR):" in result
class TestServerIntegration:
"""Test server module integration."""
def test_mcp_instance_exists(self):
"""Test that mcp instance is properly initialized."""
assert mcp is not None
assert hasattr(mcp, "tool")
def test_constants_defined(self):
"""Test that all required constants are defined."""
assert NIXOS_API == "https://search.nixos.org/backend"
assert NIXOS_AUTH == ("aWVSALXpZv", "X8gPHnzL52wFEekuxsfQ9cSh")
assert HOME_MANAGER_URL == "https://nix-community.github.io/home-manager/options.xhtml"
assert DARWIN_URL == "https://nix-darwin.github.io/nix-darwin/manual/index.html"
channels = get_channels()
assert "unstable" in channels
assert "stable" in channels
def test_all_tools_decorated(self):
"""Test that all tool functions are properly decorated."""
# Tool functions should be registered with mcp and have underlying functions
tool_names = [
"nixos_search",
"nixos_info",
"nixos_stats",
"home_manager_search",
"home_manager_info",
"home_manager_stats",
"home_manager_list_options",
"home_manager_options_by_prefix",
"darwin_search",
"darwin_info",
"darwin_stats",
"darwin_list_options",
"darwin_options_by_prefix",
]
for tool_name in tool_names:
# FastMCP decorates functions, so they should have the original function available
tool = getattr(server, tool_name)
assert hasattr(tool, "fn"), f"Tool {tool_name} should have 'fn' attribute"
assert callable(tool.fn), f"Tool {tool_name}.fn should be callable"
```