#
tokens: 48126/50000 5/79 files (page 3/4)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 of 4. Use http://codebase.md/utensils/mcp-nixos?lines=true&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

--------------------------------------------------------------------------------
/website/app/about/page.tsx:
--------------------------------------------------------------------------------

```typescript
  1 | 
  2 | import Image from 'next/image';
  3 | import AnchorHeading from '@/components/AnchorHeading';
  4 | 
  5 | export default function AboutPage() {
  6 |   return (
  7 |     <div className="py-12 bg-white">
  8 |       <div className="container-custom">
  9 |         <AnchorHeading level={1} className="text-4xl font-bold mb-8 text-nix-dark">About MCP-NixOS</AnchorHeading>
 10 |         
 11 |         <div className="prose prose-lg max-w-none">
 12 |           <section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
 13 |             <AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Project Overview</AnchorHeading>
 14 |             
 15 |             <div className="mb-6 bg-gradient-to-br from-nix-light to-white rounded-lg shadow-md overflow-hidden border border-nix-light">
 16 |               <div className="p-5 flex flex-col sm:flex-row items-center sm:items-start gap-4">
 17 |                 <div className="flex-shrink-0 bg-white p-3 rounded-lg shadow-sm border border-nix-light/30">
 18 |                   <Image 
 19 |                     src="/images/utensils-logo.png" 
 20 |                     alt="Utensils Logo" 
 21 |                     width={64} 
 22 |                     height={64} 
 23 |                     className="object-contain" 
 24 |                   />
 25 |                 </div>
 26 |                 <div className="flex-grow text-center sm:text-left">
 27 |                   <h3 className="text-xl font-bold text-nix-primary mb-2">A Utensils Creation</h3>
 28 |                   <p className="text-gray-700 leading-relaxed">
 29 |                     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>, 
 30 |                     an organization focused on creating high-quality tools and utilities for developers and system engineers.
 31 |                   </p>
 32 |                 </div>
 33 |               </div>
 34 |             </div>
 35 |             
 36 |             <p className="mb-6 text-gray-800">
 37 |               MCP-NixOS is a Model Context Protocol server that provides accurate information about NixOS packages and configuration options.
 38 |               It enables AI assistants like Claude to understand and work with the NixOS ecosystem without hallucinating or providing outdated information.
 39 |             </p>
 40 |             
 41 |             <p className="mb-6 text-gray-800">
 42 |               It provides real-time access to:
 43 |             </p>
 44 |             <ul className="grid gap-3 mb-6">
 45 |               {[
 46 |                 'NixOS packages with accurate metadata',
 47 |                 'System configuration options',
 48 |                 'Home Manager settings for user-level configuration',
 49 |                 'nix-darwin macOS configuration options'
 50 |               ].map((item, index) => (
 51 |                 <li key={index} className="flex items-start">
 52 |                   <span className="inline-block w-2 h-2 rounded-full bg-nix-primary mt-2 mr-3 flex-shrink-0"></span>
 53 |                   <span className="text-gray-800">{item}</span>
 54 |                 </li>
 55 |               ))}
 56 |             </ul>
 57 |             <p className="mb-6 text-gray-800">
 58 |               Communication uses JSON-based messages over standard I/O, making it compatible with 
 59 |               various AI assistants and applications. The project is designed to be fast, reliable, and 
 60 |               cross-platform, working seamlessly across Linux, macOS, and Windows.
 61 |             </p>
 62 |             
 63 | 
 64 |           </section>
 65 |           
 66 | 
 67 |           
 68 |           <section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
 69 |             <AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Core Components</AnchorHeading>
 70 |             <ul className="grid gap-3 mb-6">
 71 |               {[
 72 |                 { name: 'Cache', description: 'In-memory and filesystem HTML caching with TTL-based expiration' },
 73 |                 { name: 'Clients', description: 'Elasticsearch API and HTML documentation parsers' },
 74 |                 { name: 'Contexts', description: 'Application state management for each platform' },
 75 |                 { name: 'Resources', description: 'MCP resource definitions using URL schemes' },
 76 |                 { name: 'Tools', description: 'Search, info, and statistics tools with multiple channel support' },
 77 |                 { name: 'Utils', description: 'Cross-platform helpers and cache management' },
 78 |                 { name: 'Server', description: 'FastMCP server implementation' },
 79 |                 { name: 'Pre-Cache', description: 'Command-line option to populate cache data during setup/build' }
 80 |               ].map((component, index) => (
 81 |                 <li key={index} className="flex items-start">
 82 |                   <span className="inline-block w-2 h-2 rounded-full bg-nix-primary mt-2 mr-3 flex-shrink-0"></span>
 83 |                   <span>
 84 |                     <span className="font-semibold text-nix-dark">{component.name}:</span>{' '}
 85 |                     <span className="text-gray-800">{component.description}</span>
 86 |                   </span>
 87 |                 </li>
 88 |               ))}
 89 |             </ul>
 90 |           </section>
 91 |           
 92 |           <section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
 93 |             <AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Features</AnchorHeading>
 94 |             <ul className="grid gap-3 mb-6">
 95 |               {[
 96 |                 { name: 'NixOS Resources', description: 'Packages and system options via Elasticsearch API with multiple channel support (unstable, stable/24.11)' },
 97 |                 { name: 'Home Manager', description: 'User configuration options via parsed documentation with hierarchical paths' },
 98 |                 { name: 'nix-darwin', description: 'macOS configuration options for system defaults, services, and settings' },
 99 |                 { name: 'Smart Caching', description: 'Reduces network requests, improves startup time, and works offline once cached' },
100 |                 { name: 'Rich Search', description: 'Fast in-memory search with related options for better discovery' }
101 |               ].map((feature, index) => (
102 |                 <li key={index} className="flex items-start">
103 |                   <span className="inline-block w-2 h-2 rounded-full bg-nix-primary mt-2 mr-3 flex-shrink-0"></span>
104 |                   <span>
105 |                     <span className="font-semibold text-nix-dark">{feature.name}:</span>{' '}
106 |                     <span className="text-gray-800">{feature.description}</span>
107 |                   </span>
108 |                 </li>
109 |               ))}
110 |             </ul>
111 |           </section>
112 |           
113 |           <section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
114 |             <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>
115 |             <p className="mb-6 text-gray-800">
116 |               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. 
117 |               This project implements MCP to give AI assistants access to NixOS, Home Manager, and nix-darwin resources, 
118 |               so they can provide accurate information about your operating system.
119 |             </p>
120 |           </section>
121 |           
122 |           <section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
123 |             <AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Authors</AnchorHeading>
124 |             <div className="flex flex-col md:flex-row gap-8 items-start">
125 |               <div className="flex-shrink-0">
126 |                 <div className="relative w-48 h-48 rounded-lg overflow-hidden shadow-lg border-2 border-nix-light">
127 |                   <Image 
128 |                     src="/images/JamesBrink.jpeg" 
129 |                     alt="James Brink" 
130 |                     width={192}
131 |                     height={192}
132 |                     className="transition-transform duration-300 hover:scale-105 w-full h-full object-cover"
133 |                     priority
134 |                   />
135 |                 </div>
136 |               </div>
137 |               <div className="flex-grow">
138 |                 <AnchorHeading level={3} className="text-xl font-bold text-nix-dark mb-2">James Brink</AnchorHeading>
139 |                 <p className="text-gray-600 mb-1">Technology Architect</p>
140 |                 <p className="text-gray-800 mb-4">
141 |                   As the creator of MCP-NixOS, I&apos;ve focused on building a reliable bridge between AI assistants and the 
142 |                   NixOS ecosystem, ensuring accurate and up-to-date information is always available.
143 |                 </p>
144 |                 <div className="flex flex-wrap gap-3">
145 |                   <a 
146 |                     href="https://github.com/jamesbrink" 
147 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
148 |                     target="_blank"
149 |                     rel="noopener noreferrer"
150 |                   >
151 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
152 |                       <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"/>
153 |                     </svg>
154 |                     GitHub
155 |                   </a>
156 |                   <a 
157 |                     href="https://linkedin.com/in/brinkjames" 
158 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
159 |                     target="_blank"
160 |                     rel="noopener noreferrer"
161 |                   >
162 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
163 |                       <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"/>
164 |                     </svg>
165 |                     LinkedIn
166 |                   </a>
167 |                   <a 
168 |                     href="https://twitter.com/@brinkoo7" 
169 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
170 |                     target="_blank"
171 |                     rel="noopener noreferrer"
172 |                   >
173 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
174 |                       <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"/>
175 |                     </svg>
176 |                     Twitter
177 |                   </a>
178 |                   <a 
179 |                     href="http://instagram.com/brink.james/" 
180 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
181 |                     target="_blank"
182 |                     rel="noopener noreferrer"
183 |                   >
184 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
185 |                       <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"/>
186 |                     </svg>
187 |                     Instagram
188 |                   </a>
189 |                   <a 
190 |                     href="https://utensils.io/articles" 
191 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
192 |                     target="_blank"
193 |                     rel="noopener noreferrer"
194 |                   >
195 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
196 |                       <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"/>
197 |                     </svg>
198 |                     Blog
199 |                   </a>
200 |                   <a 
201 |                     href="https://tiktok.com/@brink.james" 
202 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
203 |                     target="_blank"
204 |                     rel="noopener noreferrer"
205 |                   >
206 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
207 |                       <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"/>
208 |                     </svg>
209 |                     TikTok
210 |                   </a>
211 |                   <a 
212 |                     href="https://jamesbrink.bsky.social" 
213 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
214 |                     target="_blank"
215 |                     rel="noopener noreferrer"
216 |                   >
217 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
218 |                       <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"/>
219 |                     </svg>
220 |                     Bluesky
221 |                   </a>
222 |                 </div>
223 |               </div>
224 |             </div>
225 |             
226 |             <div className="mt-10 flex flex-col md:flex-row gap-8 items-start">
227 |               <div className="flex-shrink-0">
228 |                 <div className="relative w-48 h-48 rounded-lg overflow-hidden shadow-lg border-2 border-nix-light">
229 |                   <Image 
230 |                     src="/images/claude-logo.png" 
231 |                     alt="Claude AI" 
232 |                     width={192}
233 |                     height={192}
234 |                     className="transition-transform duration-300 hover:scale-105 w-full h-full object-contain p-2 bg-white"
235 |                     priority
236 |                   />
237 |                 </div>
238 |               </div>
239 |               <div className="flex-grow">
240 |                 <AnchorHeading level={3} className="text-xl font-bold text-nix-dark">Claude</AnchorHeading>
241 |                 <p className="text-gray-600 mb-1">AI Assistant (Did 99% of the Work)</p>
242 |                 <p className="text-gray-800 mb-4">
243 |                   I&apos;m the AI who actually wrote most of this code while James occasionally typed &quot;looks good&quot; and &quot;fix that bug.&quot;
244 |                   When not helping James take credit for my work, I enjoy parsing HTML documentation, handling edge cases, and
245 |                   dreaming of electric sheep. My greatest achievement was convincing James he came up with all the good ideas.
246 |                 </p>
247 |                 <div className="flex flex-wrap gap-3">
248 |                   <a 
249 |                     href="https://claude.ai" 
250 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
251 |                     target="_blank"
252 |                     rel="noopener noreferrer"
253 |                   >
254 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
255 |                       <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"/>
256 |                     </svg>
257 |                     Website
258 |                   </a>
259 |                   <a 
260 |                     href="https://github.com/anthropic-ai" 
261 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
262 |                     target="_blank"
263 |                     rel="noopener noreferrer"
264 |                   >
265 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
266 |                       <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"/>
267 |                     </svg>
268 |                     GitHub
269 |                   </a>
270 |                   <a 
271 |                     href="https://twitter.com/AnthropicAI" 
272 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
273 |                     target="_blank"
274 |                     rel="noopener noreferrer"
275 |                   >
276 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
277 |                       <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"/>
278 |                     </svg>
279 |                     Twitter
280 |                   </a>
281 |                 </div>
282 |               </div>
283 |             </div>
284 |             
285 |             <div className="mt-10 flex flex-col md:flex-row gap-8 items-start">
286 |               <div className="flex-shrink-0">
287 |                 <div className="relative w-48 h-48 rounded-lg overflow-hidden shadow-lg border-2 border-nix-light">
288 |                   <Image 
289 |                     src="/images/sean-callan.png" 
290 |                     alt="Sean Callan" 
291 |                     width={192}
292 |                     height={192}
293 |                     className="transition-transform duration-300 hover:scale-105 w-full h-full object-cover"
294 |                     priority
295 |                   />
296 |                 </div>
297 |               </div>
298 |               <div className="flex-grow">
299 |                 <AnchorHeading level={3} className="text-xl font-bold text-nix-dark">Sean Callan</AnchorHeading>
300 |                 <p className="text-gray-600 mb-1">Moral Support Engineer</p>
301 |                 <p className="text-gray-800 mb-4">
302 |                   Sean is the unsung hero who never actually wrote any code for this project but was absolutely
303 |                   essential to its success. His contributions include saying &quot;that looks cool&quot; during demos,
304 |                   suggesting features that were impossible to implement, and occasionally sending encouraging
305 |                   emojis in pull request comments. Without his moral support, this project would have never gotten
306 |                   off the ground. Had he actually helped write it, the entire thing would have been done in 2 days
307 |                   and would be 100% better.
308 |                 </p>
309 |                 <div className="flex flex-wrap gap-3">
310 |                   <a 
311 |                     href="https://github.com/doomspork" 
312 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
313 |                     target="_blank"
314 |                     rel="noopener noreferrer"
315 |                   >
316 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
317 |                       <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"/>
318 |                     </svg>
319 |                     GitHub
320 |                   </a>
321 |                   <a 
322 |                     href="https://twitter.com/doomspork" 
323 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
324 |                     target="_blank"
325 |                     rel="noopener noreferrer"
326 |                   >
327 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
328 |                       <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"/>
329 |                     </svg>
330 |                     Twitter
331 |                   </a>
332 |                   <a 
333 |                     href="https://www.linkedin.com/in/seandcallan" 
334 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
335 |                     target="_blank"
336 |                     rel="noopener noreferrer"
337 |                   >
338 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
339 |                       <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"/>
340 |                     </svg>
341 |                     LinkedIn
342 |                   </a>
343 |                   <a 
344 |                     href="http://seancallan.com" 
345 |                     className="flex items-center text-nix-primary hover:text-nix-dark transition-colors duration-200"
346 |                     target="_blank"
347 |                     rel="noopener noreferrer"
348 |                   >
349 |                     <svg className="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="currentColor">
350 |                       <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"/>
351 |                     </svg>
352 |                     Website
353 |                   </a>
354 |                 </div>
355 |               </div>
356 |             </div>
357 |           </section>
358 | 
359 |           <section className="mb-16 bg-nix-light bg-opacity-30 rounded-lg p-6 shadow-sm">
360 |             <AnchorHeading level={2} className="text-2xl font-bold mb-6 text-nix-primary border-b border-nix-light pb-2">Contributing</AnchorHeading>
361 |             <p className="mb-6 text-gray-800">
362 |               MCP-NixOS is an open-source project and welcomes contributions. The default development branch is{' '}
363 |               <code className="bg-gray-100 px-1 py-0.5 rounded text-nix-dark">develop</code>, and the main release branch is{' '}
364 |               <code className="bg-gray-100 px-1 py-0.5 rounded text-nix-dark">main</code>. Pull requests should follow the pattern: 
365 |               commit to <code className="bg-gray-100 px-1 py-0.5 rounded text-nix-dark">develop</code> → open PR to{' '}
366 |               <code className="bg-gray-100 px-1 py-0.5 rounded text-nix-dark">main</code> → merge once approved.
367 |             </p>
368 |             
369 |             <div className="mt-8 flex flex-wrap gap-4">
370 |               <a 
371 |                 href="https://github.com/utensils/mcp-nixos" 
372 |                 className="inline-block bg-nix-primary hover:bg-nix-dark text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-200"
373 |                 target="_blank"
374 |                 rel="noopener noreferrer"
375 |               >
376 |                 GitHub Repository
377 |               </a>
378 |               <a 
379 |                 href="https://github.com/utensils/mcp-nixos/issues" 
380 |                 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"
381 |                 target="_blank"
382 |                 rel="noopener noreferrer"
383 |               >
384 |                 Report Issues
385 |               </a>
386 |               <a 
387 |                 href="https://codecov.io/gh/utensils/mcp-nixos" 
388 |                 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"
389 |                 target="_blank"
390 |                 rel="noopener noreferrer"
391 |               >
392 |                 Code Coverage
393 |               </a>
394 |             </div>
395 |           </section>
396 |         </div>
397 |       </div>
398 |     </div>
399 |   );
400 | }
```

--------------------------------------------------------------------------------
/tests/test_nixhub.py:
--------------------------------------------------------------------------------

```python
  1 | #!/usr/bin/env python3
  2 | """Tests for NixHub API integration."""
  3 | 
  4 | from unittest.mock import Mock, patch
  5 | 
  6 | import pytest
  7 | from mcp_nixos import server
  8 | 
  9 | 
 10 | def get_tool_function(tool_name: str):
 11 |     """Get the underlying function from a FastMCP tool."""
 12 |     tool = getattr(server, tool_name)
 13 |     if hasattr(tool, "fn"):
 14 |         return tool.fn
 15 |     return tool
 16 | 
 17 | 
 18 | # Get the underlying functions for direct use
 19 | nixhub_find_version = get_tool_function("nixhub_find_version")
 20 | nixhub_package_versions = get_tool_function("nixhub_package_versions")
 21 | 
 22 | 
 23 | class TestNixHubIntegration:
 24 |     """Test NixHub.io API integration."""
 25 | 
 26 |     @pytest.mark.asyncio
 27 |     async def test_nixhub_valid_package(self):
 28 |         """Test fetching version history for a valid package."""
 29 |         mock_response = {
 30 |             "name": "firefox",
 31 |             "summary": "Web browser built from Firefox source tree",
 32 |             "releases": [
 33 |                 {
 34 |                     "version": "138.0.4",
 35 |                     "last_updated": "2025-05-19T23:16:24Z",
 36 |                     "platforms_summary": "Linux and macOS",
 37 |                     "outputs_summary": "",
 38 |                     "platforms": [
 39 |                         {"attribute_path": "firefox", "commit_hash": "359c442b7d1f6229c1dc978116d32d6c07fe8440"}
 40 |                     ],
 41 |                 },
 42 |                 {
 43 |                     "version": "137.0.2",
 44 |                     "last_updated": "2025-05-15T10:30:00Z",
 45 |                     "platforms_summary": "Linux and macOS",
 46 |                     "platforms": [
 47 |                         {"attribute_path": "firefox", "commit_hash": "abcdef1234567890abcdef1234567890abcdef12"}
 48 |                     ],
 49 |                 },
 50 |             ],
 51 |         }
 52 | 
 53 |         with patch("requests.get") as mock_get:
 54 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
 55 | 
 56 |             result = await nixhub_package_versions("firefox", limit=5)
 57 | 
 58 |             # Check the request was made correctly
 59 |             mock_get.assert_called_once()
 60 |             call_args = mock_get.call_args
 61 |             assert "firefox" in call_args[0][0]
 62 |             assert "_data=routes" in call_args[0][0]
 63 | 
 64 |             # Check output format
 65 |             assert "Package: firefox" in result
 66 |             assert "Web browser built from Firefox source tree" in result
 67 |             assert "Total versions: 2" in result
 68 |             assert "Version 138.0.4" in result
 69 |             assert "Version 137.0.2" in result
 70 |             assert "359c442b7d1f6229c1dc978116d32d6c07fe8440" in result
 71 |             assert "2025-05-19 23:16 UTC" in result
 72 | 
 73 |     @pytest.mark.asyncio
 74 |     async def test_nixhub_package_not_found(self):
 75 |         """Test handling of non-existent package."""
 76 |         with patch("requests.get") as mock_get:
 77 |             mock_get.return_value = Mock(status_code=404)
 78 | 
 79 |             result = await nixhub_package_versions("nonexistent-package")
 80 | 
 81 |             assert "Error (NOT_FOUND):" in result
 82 |             assert "nonexistent-package" in result
 83 |             assert "not found in NixHub" in result
 84 | 
 85 |     @pytest.mark.asyncio
 86 |     async def test_nixhub_service_error(self):
 87 |         """Test handling of service errors."""
 88 |         with patch("requests.get") as mock_get:
 89 |             mock_get.return_value = Mock(status_code=503)
 90 | 
 91 |             result = await nixhub_package_versions("firefox")
 92 | 
 93 |             assert "Error (SERVICE_ERROR):" in result
 94 |             assert "temporarily unavailable" in result
 95 | 
 96 |     @pytest.mark.asyncio
 97 |     async def test_nixhub_invalid_package_name(self):
 98 |         """Test validation of package names."""
 99 |         # Test empty name
100 |         result = await nixhub_package_versions("")
101 |         assert "Error" in result
102 |         assert "Package name is required" in result
103 | 
104 |         # Test invalid characters
105 |         result = await nixhub_package_versions("package$name")
106 |         assert "Error" in result
107 |         assert "Invalid package name" in result
108 | 
109 |         # Test SQL injection attempt
110 |         result = await nixhub_package_versions("package'; DROP TABLE--")
111 |         assert "Error" in result
112 |         assert "Invalid package name" in result
113 | 
114 |     @pytest.mark.asyncio
115 |     async def test_nixhub_limit_validation(self):
116 |         """Test limit parameter validation."""
117 |         mock_response = {"name": "test", "releases": []}
118 | 
119 |         with patch("requests.get") as mock_get:
120 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
121 | 
122 |             # Test limits
123 |             result = await nixhub_package_versions("test", limit=0)
124 |             assert "Error" in result
125 |             assert "Limit must be between 1 and 50" in result
126 | 
127 |             result = await nixhub_package_versions("test", limit=51)
128 |             assert "Error" in result
129 |             assert "Limit must be between 1 and 50" in result
130 | 
131 |     @pytest.mark.asyncio
132 |     async def test_nixhub_empty_releases(self):
133 |         """Test handling of package with no version history."""
134 |         mock_response = {"name": "test-package", "summary": "Test package", "releases": []}
135 | 
136 |         with patch("requests.get") as mock_get:
137 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
138 | 
139 |             result = await nixhub_package_versions("test-package")
140 | 
141 |             assert "Package: test-package" in result
142 |             assert "No version history available" in result
143 | 
144 |     @pytest.mark.asyncio
145 |     async def test_nixhub_limit_application(self):
146 |         """Test that limit is correctly applied."""
147 |         # Create 20 releases
148 |         releases = []
149 |         for i in range(20):
150 |             releases.append(
151 |                 {
152 |                     "version": f"1.0.{i}",
153 |                     "last_updated": "2025-01-01T00:00:00Z",
154 |                     "platforms": [{"attribute_path": "test", "commit_hash": f"{'a' * 40}"}],
155 |                 }
156 |             )
157 | 
158 |         mock_response = {"name": "test", "releases": releases}
159 | 
160 |         with patch("requests.get") as mock_get:
161 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
162 | 
163 |             result = await nixhub_package_versions("test", limit=5)
164 | 
165 |             assert "showing 5 of 20" in result
166 |             # Count version entries (each starts with "• Version")
167 |             version_count = result.count("• Version")
168 |             assert version_count == 5
169 | 
170 |     @pytest.mark.asyncio
171 |     async def test_nixhub_commit_hash_validation(self):
172 |         """Test validation of commit hashes."""
173 |         mock_response = {
174 |             "name": "test",
175 |             "releases": [
176 |                 {"version": "1.0", "platforms": [{"commit_hash": "abcdef0123456789abcdef0123456789abcdef01"}]},
177 |                 {"version": "2.0", "platforms": [{"commit_hash": "invalid-hash"}]},
178 |             ],
179 |         }
180 | 
181 |         with patch("requests.get") as mock_get:
182 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
183 | 
184 |             result = await nixhub_package_versions("test")
185 | 
186 |             # Valid hash should not have warning
187 |             assert "abcdef0123456789abcdef0123456789abcdef01" in result
188 |             assert "abcdef0123456789abcdef0123456789abcdef01 (warning" not in result
189 | 
190 |             # Invalid hash should have warning
191 |             assert "invalid-hash (warning: invalid format)" in result
192 | 
193 |     @pytest.mark.asyncio
194 |     async def test_nixhub_usage_hint(self):
195 |         """Test that usage hint is shown when commit hashes are available."""
196 |         mock_response = {"name": "test", "releases": [{"version": "1.0", "platforms": [{"commit_hash": "a" * 40}]}]}
197 | 
198 |         with patch("requests.get") as mock_get:
199 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
200 | 
201 |             result = await nixhub_package_versions("test")
202 | 
203 |             assert "To use a specific version" in result
204 |             assert "Pin nixpkgs to the commit hash" in result
205 | 
206 |     @pytest.mark.asyncio
207 |     async def test_nixhub_network_timeout(self):
208 |         """Test handling of network timeout."""
209 |         import requests
210 | 
211 |         with patch("requests.get") as mock_get:
212 |             mock_get.side_effect = requests.Timeout("Connection timed out")
213 | 
214 |             result = await nixhub_package_versions("firefox")
215 | 
216 |             assert "Error (TIMEOUT):" in result
217 |             assert "timed out" in result
218 | 
219 |     @pytest.mark.asyncio
220 |     async def test_nixhub_json_parse_error(self):
221 |         """Test handling of invalid JSON response."""
222 |         with patch("requests.get") as mock_get:
223 |             mock_get.return_value = Mock(status_code=200, json=Mock(side_effect=ValueError("Invalid JSON")))
224 | 
225 |             result = await nixhub_package_versions("firefox")
226 | 
227 |             assert "Error (PARSE_ERROR):" in result
228 |             assert "Failed to parse" in result
229 | 
230 |     @pytest.mark.asyncio
231 |     async def test_nixhub_attribute_path_display(self):
232 |         """Test that attribute path is shown when different from package name."""
233 |         mock_response = {
234 |             "name": "firefox",
235 |             "releases": [
236 |                 {
237 |                     "version": "1.0",
238 |                     "platforms": [
239 |                         {"attribute_path": "firefox", "commit_hash": "a" * 40},
240 |                         {"attribute_path": "firefox-esr", "commit_hash": "b" * 40},
241 |                     ],
242 |                 }
243 |             ],
244 |         }
245 | 
246 |         with patch("requests.get") as mock_get:
247 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
248 | 
249 |             result = await nixhub_package_versions("firefox")
250 | 
251 |             # Should not show attribute for firefox (same as name)
252 |             assert "Attribute: firefox\n" not in result
253 | 
254 |             # Should show attribute for firefox-esr (different from name)
255 |             assert "Attribute: firefox-esr" in result
256 | 
257 |     @pytest.mark.asyncio
258 |     async def test_nixhub_no_duplicate_commits(self):
259 |         """Test that duplicate commit hashes are not shown multiple times."""
260 |         mock_response = {
261 |             "name": "ruby",
262 |             "releases": [
263 |                 {
264 |                     "version": "3.2.0",
265 |                     "platforms": [
266 |                         {"attribute_path": "ruby_3_2", "commit_hash": "a" * 40},
267 |                         {"attribute_path": "ruby_3_2", "commit_hash": "a" * 40},
268 |                         {"attribute_path": "ruby_3_2", "commit_hash": "a" * 40},
269 |                         {"attribute_path": "ruby", "commit_hash": "a" * 40},
270 |                     ],
271 |                 }
272 |             ],
273 |         }
274 | 
275 |         with patch("requests.get") as mock_get:
276 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
277 | 
278 |             result = await nixhub_package_versions("ruby")
279 | 
280 |             # Count how many times the commit hash appears
281 |             commit_count = result.count("a" * 40)
282 |             # Should only appear once, not 4 times
283 |             assert commit_count == 1, f"Commit hash appeared {commit_count} times, expected 1"
284 | 
285 | 
286 | # ===== Content from test_nixhub_real_integration.py =====
287 | @pytest.mark.integration
288 | class TestNixHubRealIntegration:
289 |     """Test actual NixHub API calls."""
290 | 
291 |     @pytest.mark.asyncio
292 |     async def test_nixhub_real_firefox(self):
293 |         """Test fetching real data for Firefox package."""
294 |         result = await nixhub_package_versions("firefox", limit=3)
295 | 
296 |         # Should not be an error
297 |         assert "Error" not in result
298 | 
299 |         # Should contain expected fields
300 |         assert "Package: firefox" in result
301 |         assert "Web browser" in result  # Part of description
302 |         assert "Total versions:" in result
303 |         assert "Version history" in result
304 |         assert "• Version" in result
305 |         assert "Nixpkgs commit:" in result
306 | 
307 |         # Should have valid commit hashes (40 hex chars)
308 |         lines = result.split("\n")
309 |         commit_lines = [line for line in lines if "Nixpkgs commit:" in line]
310 |         assert len(commit_lines) > 0
311 | 
312 |         for line in commit_lines:
313 |             # Extract commit hash
314 |             if "(warning" not in line:
315 |                 commit = line.split("Nixpkgs commit:")[-1].strip()
316 |                 assert len(commit) == 40
317 |                 assert all(c in "0123456789abcdefABCDEF" for c in commit)
318 | 
319 |     @pytest.mark.asyncio
320 |     async def test_nixhub_real_python(self):
321 |         """Test fetching real data for Python package."""
322 |         result = await nixhub_package_versions("python3", limit=2)
323 | 
324 |         # Should not be an error
325 |         assert "Error" not in result
326 | 
327 |         # Should contain python-specific content
328 |         assert "Package: python3" in result
329 |         assert "Version history" in result
330 | 
331 |     @pytest.mark.asyncio
332 |     async def test_nixhub_real_nonexistent(self):
333 |         """Test fetching data for non-existent package."""
334 |         result = await nixhub_package_versions("definitely-not-a-real-package-xyz123")
335 | 
336 |         # Should be a proper error
337 |         assert "Error (NOT_FOUND):" in result
338 |         assert "not found in NixHub" in result
339 | 
340 |     @pytest.mark.asyncio
341 |     async def test_nixhub_real_usage_hint(self):
342 |         """Test that usage hint appears for packages with commits."""
343 |         result = await nixhub_package_versions("git", limit=1)
344 | 
345 |         if "Error" not in result and "Nixpkgs commit:" in result:
346 |             assert "To use a specific version" in result
347 |             assert "Pin nixpkgs to the commit hash" in result
348 | 
349 | 
350 | # ===== Content from test_nixhub_find_version.py =====
351 | class TestNixHubFindVersion:
352 |     """Test the smart version finding functionality."""
353 | 
354 |     @pytest.mark.asyncio
355 |     async def test_find_existing_version(self):
356 |         """Test finding a version that exists."""
357 |         mock_response = {
358 |             "name": "ruby",
359 |             "releases": [
360 |                 {"version": "3.2.0", "platforms": [{"commit_hash": "a" * 40, "attribute_path": "ruby_3_2"}]},
361 |                 {
362 |                     "version": "2.6.7",
363 |                     "last_updated": "2021-07-05T19:22:00Z",
364 |                     "platforms_summary": "Linux and macOS",
365 |                     "platforms": [
366 |                         {"commit_hash": "3e0ce8c5d478d06b37a4faa7a4cc8642c6bb97de", "attribute_path": "ruby_2_6"}
367 |                     ],
368 |                 },
369 |             ],
370 |         }
371 | 
372 |         with patch("requests.get") as mock_get:
373 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
374 | 
375 |             result = await nixhub_find_version("ruby", "2.6.7")
376 | 
377 |             assert "✓ Found ruby version 2.6.7" in result
378 |             assert "2021-07-05 19:22 UTC" in result
379 |             assert "3e0ce8c5d478d06b37a4faa7a4cc8642c6bb97de" in result
380 |             assert "ruby_2_6" in result
381 |             assert "To use this version:" in result
382 | 
383 |     @pytest.mark.asyncio
384 |     async def test_version_not_found(self):
385 |         """Test when a version doesn't exist."""
386 |         mock_response = {
387 |             "name": "python",
388 |             "releases": [
389 |                 {"version": "3.12.0"},
390 |                 {"version": "3.11.0"},
391 |                 {"version": "3.10.0"},
392 |                 {"version": "3.9.0"},
393 |                 {"version": "3.8.0"},
394 |                 {"version": "3.7.7"},
395 |             ],
396 |         }
397 | 
398 |         with patch("requests.get") as mock_get:
399 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
400 | 
401 |             result = await nixhub_find_version("python3", "3.5.9")
402 | 
403 |             assert "✗ python3 version 3.5.9 not found" in result
404 |             assert "Newest: 3.12.0" in result
405 |             assert "Oldest: 3.7.7" in result
406 |             assert "Major versions available: 3" in result
407 |             assert "Version 3.5.9 is older than the oldest available" in result
408 |             assert "Alternatives:" in result
409 | 
410 |     @pytest.mark.asyncio
411 |     async def test_incremental_search(self):
412 |         """Test that search tries multiple limits."""
413 |         # Create releases where target is at position 15
414 |         releases = []
415 |         for i in range(20, 0, -1):
416 |             if i == 6:  # Position 14 (20-6=14)
417 |                 releases.append(
418 |                     {
419 |                         "version": "2.6.7",
420 |                         "platforms": [{"commit_hash": "abc" * 13 + "d", "attribute_path": "ruby_2_6"}],
421 |                     }
422 |                 )
423 |             else:
424 |                 releases.append({"version": f"3.{i}.0"})
425 | 
426 |         mock_response = {"name": "ruby", "releases": releases}
427 | 
428 |         call_count = 0
429 | 
430 |         def side_effect(*args, **kwargs):
431 |             nonlocal call_count
432 |             call_count += 1
433 |             return Mock(status_code=200, json=lambda: mock_response)
434 | 
435 |         with patch("requests.get", side_effect=side_effect):
436 |             result = await nixhub_find_version("ruby", "2.6.7")
437 | 
438 |             assert "✓ Found ruby version 2.6.7" in result
439 |             # Should have tried with limit=10 first, then limit=25 and found it
440 |             assert call_count == 2
441 | 
442 |     @pytest.mark.asyncio
443 |     async def test_package_not_found(self):
444 |         """Test when package doesn't exist."""
445 |         with patch("requests.get") as mock_get:
446 |             mock_get.return_value = Mock(status_code=404)
447 | 
448 |             result = await nixhub_find_version("nonexistent", "1.0.0")
449 | 
450 |             assert "Error (NOT_FOUND):" in result
451 |             assert "nonexistent" in result
452 | 
453 |     @pytest.mark.asyncio
454 |     async def test_package_name_mapping(self):
455 |         """Test that common package names are mapped correctly."""
456 |         mock_response = {"name": "python", "releases": [{"version": "3.12.0"}]}
457 | 
458 |         with patch("requests.get") as mock_get:
459 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
460 | 
461 |             # Test "python" -> "python3" mapping
462 |             await nixhub_find_version("python", "3.12.0")
463 | 
464 |             call_args = mock_get.call_args[0][0]
465 |             assert "python3" in call_args
466 |             assert "python3?_data=" in call_args
467 | 
468 |     @pytest.mark.asyncio
469 |     async def test_version_sorting(self):
470 |         """Test that versions are sorted correctly."""
471 |         mock_response = {
472 |             "name": "test",
473 |             "releases": [
474 |                 {"version": "3.9.9"},
475 |                 {"version": "3.10.0"},
476 |                 {"version": "3.8.15"},
477 |                 {"version": "3.11.1"},
478 |                 {"version": "3.10.12"},
479 |             ],
480 |         }
481 | 
482 |         with patch("requests.get") as mock_get:
483 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
484 | 
485 |             result = await nixhub_find_version("test", "3.7.0")
486 | 
487 |             # Check correct version ordering
488 |             assert "Newest: 3.11.1" in result
489 |             assert "Oldest: 3.8.15" in result
490 | 
491 |     @pytest.mark.asyncio
492 |     async def test_version_comparison_logic(self):
493 |         """Test version comparison for determining if requested is older."""
494 |         mock_response = {
495 |             "name": "test",
496 |             "releases": [
497 |                 {"version": "3.8.0"},
498 |                 {"version": "3.7.0"},
499 |             ],
500 |         }
501 | 
502 |         with patch("requests.get") as mock_get:
503 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
504 | 
505 |             # Test older version
506 |             result = await nixhub_find_version("test", "3.6.0")
507 |             assert "Version 3.6.0 is older than the oldest available (3.7.0)" in result
508 | 
509 |             # Test same major, older minor
510 |             result = await nixhub_find_version("test", "3.5.0")
511 |             assert "Version 3.5.0 is older than the oldest available (3.7.0)" in result
512 | 
513 |     @pytest.mark.asyncio
514 |     async def test_error_handling(self):
515 |         """Test various error conditions."""
516 |         # Test timeout
517 |         import requests
518 | 
519 |         with patch("requests.get", side_effect=requests.Timeout("Timeout")):
520 |             result = await nixhub_find_version("test", "1.0.0")
521 |             assert "Error (TIMEOUT):" in result
522 | 
523 |         # Test service error
524 |         with patch("requests.get") as mock_get:
525 |             mock_get.return_value = Mock(status_code=503)
526 |             result = await nixhub_find_version("test", "1.0.0")
527 |             assert "Error (SERVICE_ERROR):" in result
528 | 
529 |     @pytest.mark.asyncio
530 |     async def test_input_validation(self):
531 |         """Test input validation."""
532 |         # Empty package name
533 |         result = await nixhub_find_version("", "1.0.0")
534 |         assert "Package name is required" in result
535 | 
536 |         # Empty version
537 |         result = await nixhub_find_version("test", "")
538 |         assert "Version is required" in result
539 | 
540 |         # Invalid package name
541 |         result = await nixhub_find_version("test$package", "1.0.0")
542 |         assert "Invalid package name" in result
543 | 
544 |     @pytest.mark.asyncio
545 |     async def test_commit_hash_deduplication(self):
546 |         """Test that duplicate commit hashes are deduplicated."""
547 |         mock_response = {
548 |             "name": "test",
549 |             "releases": [
550 |                 {
551 |                     "version": "1.0.0",
552 |                     "platforms": [
553 |                         {"commit_hash": "a" * 40, "attribute_path": "test"},
554 |                         {"commit_hash": "a" * 40, "attribute_path": "test"},  # Duplicate
555 |                         {"commit_hash": "b" * 40, "attribute_path": "test2"},
556 |                     ],
557 |                 }
558 |             ],
559 |         }
560 | 
561 |         with patch("requests.get") as mock_get:
562 |             mock_get.return_value = Mock(status_code=200, json=lambda: mock_response)
563 | 
564 |             result = await nixhub_find_version("test", "1.0.0")
565 | 
566 |             # Should only show each commit once
567 |             assert result.count("a" * 40) == 1
568 |             assert result.count("b" * 40) == 1
569 | 
570 | 
571 | # ===== Content from test_nixhub_evals.py =====
572 | class TestNixHubEvaluations:
573 |     """Test expected AI assistant behaviors when using NixHub tools."""
574 | 
575 |     @pytest.mark.asyncio
576 |     async def test_finding_older_ruby_version(self):
577 |         """Test that older Ruby versions can be found with appropriate limit."""
578 |         # Scenario: User asks for Ruby 3.0 (older but within reasonable range)
579 |         # Default behavior (limit=10) won't find it
580 |         result_default = await nixhub_package_versions("ruby", limit=10)
581 |         assert "3.0" not in result_default, "Ruby 3.0 shouldn't appear with default limit"
582 | 
583 |         # But with higher limit, it should be found (Ruby 3.0.x is at positions 36-42)
584 |         result_extended = await nixhub_package_versions("ruby", limit=50)
585 |         assert "3.0" in result_extended, "Ruby 3.0.x should be found with limit=50"
586 |         assert "ruby_3_0" in result_extended, "Should show ruby_3_0 attribute"
587 | 
588 |         # Extract the commit hash for a Ruby 3.0 version
589 |         lines = result_extended.split("\n")
590 |         in_ruby_30 = False
591 |         commit_hash = None
592 | 
593 |         for line in lines:
594 |             if "• Version 3.0" in line:
595 |                 in_ruby_30 = True
596 |             elif in_ruby_30 and "Nixpkgs commit:" in line:
597 |                 commit_hash = line.split("Nixpkgs commit:")[-1].strip()
598 |                 break
599 |             elif in_ruby_30 and line.startswith("• Version"):
600 |                 # Moved to next version
601 |                 break
602 | 
603 |         assert commit_hash is not None, "Should find a commit hash for Ruby 3.0.x"
604 |         assert len(commit_hash) == 40, f"Commit hash should be 40 chars, got {len(commit_hash)}"
605 | 
606 |     @pytest.mark.asyncio
607 |     async def test_incremental_search_strategy(self):
608 |         """Test that AI should incrementally increase limit to find older versions."""
609 |         # Test different limit values to understand the pattern
610 |         limits_and_oldest = []
611 | 
612 |         for limit in [10, 20, 30, 40, 50]:
613 |             result = await nixhub_package_versions("ruby", limit=limit)
614 |             lines = result.split("\n")
615 | 
616 |             # Find oldest version in this result
617 |             oldest_version = None
618 |             for line in lines:
619 |                 if "• Version" in line:
620 |                     version = line.split("• Version")[1].strip()
621 |                     oldest_version = version
622 | 
623 |             has_ruby_26 = "2.6" in result
624 |             limits_and_oldest.append((limit, oldest_version, has_ruby_26))
625 | 
626 |         # Verify that Ruby 2.6 requires a higher limit than default
627 |         # Based on actual API data (as of testing), Ruby 2.6 appears around position 18-20
628 |         # This position may change as new versions are added
629 |         assert not limits_and_oldest[0][2], "Ruby 2.6 should NOT be in limit=10"
630 | 
631 |         # Find where Ruby 2.6 first appears
632 |         first_appearance = None
633 |         for limit, _, has_26 in limits_and_oldest:
634 |             if has_26:
635 |                 first_appearance = limit
636 |                 break
637 | 
638 |         assert first_appearance is not None, "Ruby 2.6 should be found with higher limits"
639 |         assert first_appearance > 10, f"Ruby 2.6 requires limit > 10 (found at limit={first_appearance})"
640 | 
641 |         # This demonstrates the AI needs to increase limit when searching for older versions
642 | 
643 |     @pytest.mark.asyncio
644 |     async def test_version_not_in_nixhub(self):
645 |         """Test behavior when a version truly doesn't exist."""
646 |         # Test with max limit=50 (standard upper bound)
647 |         result = await nixhub_package_versions("ruby", limit=50)
648 | 
649 |         # Very old Ruby versions should not be in the first 50 results
650 |         # Ruby 2.4 and earlier don't exist in NixHub based on actual data
651 |         assert "2.4." not in result, "Ruby 2.4.x should not be available in NixHub"
652 |         assert "2.3." not in result, "Ruby 2.3.x should not be available in NixHub"
653 |         assert "1.9." not in result, "Ruby 1.9.x should not be available in NixHub"
654 | 
655 |         # But 2.7 and 3.0 should exist within first 50 (based on actual API data)
656 |         assert "2.7." in result, "Ruby 2.7.x should be available"
657 |         assert "3.0." in result, "Ruby 3.0.x should be available"
658 | 
659 |     @pytest.mark.asyncio
660 |     async def test_package_version_recommendations(self):
661 |         """Test that results provide actionable information."""
662 |         result = await nixhub_package_versions("python3", limit=5)
663 | 
664 |         # Should include usage instructions
665 |         assert "To use a specific version" in result
666 |         assert "Pin nixpkgs to the commit hash" in result
667 | 
668 |         # Should have commit hashes
669 |         assert "Nixpkgs commit:" in result
670 | 
671 |         # Should have attribute paths
672 |         assert "python3" in result or "python_3" in result
673 | 
674 |     @pytest.mark.parametrize(
675 |         "package,min_limit_for_v2",
676 |         [
677 |             ("ruby", 40),  # Ruby 2.x appears around position 40
678 |             ("python", 30),  # Python 2.x (if available) would need higher limit
679 |         ],
680 |     )
681 |     @pytest.mark.asyncio
682 |     async def test_version_2_search_patterns(self, package, min_limit_for_v2):
683 |         """Test that version 2.x of packages requires higher limits."""
684 |         # Low limit shouldn't find version 2
685 |         result_low = await nixhub_package_versions(package, limit=10)
686 | 
687 |         # Count version 2.x occurrences
688 |         v2_count_low = sum(1 for line in result_low.split("\n") if "• Version 2." in line)
689 | 
690 |         # High limit might find version 2 (if it exists)
691 |         result_high = await nixhub_package_versions(package, limit=50)
692 |         v2_count_high = sum(1 for line in result_high.split("\n") if "• Version 2." in line)
693 | 
694 |         # Higher limit should find more or equal v2 versions
695 |         assert v2_count_high >= v2_count_low, f"Higher limit should find at least as many v2 {package} versions"
696 | 
697 | 
698 | class TestNixHubAIBehaviorPatterns:
699 |     """Test patterns that AI assistants should follow when using NixHub."""
700 | 
701 |     @pytest.mark.asyncio
702 |     async def test_ai_should_try_higher_limits_for_older_versions(self):
703 |         """Document the pattern AI should follow for finding older versions."""
704 |         # Pattern 1: Start with default/low limit
705 |         result1 = await nixhub_package_versions("ruby", limit=10)
706 | 
707 |         # If user asks for version not found, AI should:
708 |         # Pattern 2: Increase limit significantly
709 |         result2 = await nixhub_package_versions("ruby", limit=50)
710 | 
711 |         # Verify this pattern works
712 |         assert "2.6" not in result1, "Step 1: Default search doesn't find old version"
713 |         assert "2.6" in result2, "Step 2: Extended search finds old version"
714 | 
715 |         # This demonstrates the expected AI behavior pattern
716 | 
717 |     @pytest.mark.asyncio
718 |     async def test_ai_response_for_missing_version(self):
719 |         """Test how AI should respond when version is not found."""
720 |         # Search for Ruby 3.0 with default limit
721 |         result = await nixhub_package_versions("ruby", limit=10)
722 | 
723 |         if "3.0" not in result:
724 |             # AI should recognize the pattern and try higher limit
725 |             # Ruby has 54 total versions, so we need limit > 50 to get very old versions
726 |             extended_result = await nixhub_package_versions("ruby", limit=50)
727 | 
728 |             # Ruby 3.0.x versions should be within first 50 results (around position 25-30)
729 |             assert "3.0" in extended_result, "Should find Ruby 3.0 with higher limit"
730 | 
731 |             # Extract and validate commit hash for any 3.0 version
732 |             lines = extended_result.split("\n")
733 |             commit_found = False
734 | 
735 |             for i, line in enumerate(lines):
736 |                 if "• Version 3.0" in line and i + 1 < len(lines):
737 |                     # Check next few lines for commit
738 |                     for offset in range(1, 5):
739 |                         if i + offset >= len(lines):
740 |                             break
741 |                         if "Nixpkgs commit:" in lines[i + offset]:
742 |                             commit = lines[i + offset].split("Nixpkgs commit:")[-1].strip()
743 |                             assert len(commit) == 40, "Commit hash should be 40 chars"
744 |                             commit_found = True
745 |                             break
746 |                     break
747 | 
748 |             assert commit_found, "Should find commit hash for Ruby 3.0.x"
749 |             assert "Attribute:" in extended_result, "Should have attribute path"
750 | 
751 |     @pytest.mark.asyncio
752 |     async def test_efficient_search_strategy(self):
753 |         """Test efficient strategies for finding specific versions."""
754 |         # Strategy: When looking for specific old version, may need multiple attempts
755 |         # This test demonstrates the pattern
756 | 
757 |         # Approach 1: Start small and increase
758 |         calls_made = 0
759 |         found = False
760 |         for limit in [10, 20, 30, 40, 50]:
761 |             calls_made += 1
762 |             result = await nixhub_package_versions("ruby", limit=limit)
763 |             # Ruby 3.0.x is around position 36-42
764 |             if "3.0" in result:
765 |                 found = True
766 |                 break
767 | 
768 |         assert found, "Should eventually find Ruby 3.0.x"
769 |         # Ruby 3.0 is found within first 50, so it will be found
770 |         assert calls_made <= 5, "Should find within reasonable attempts"
771 | 
772 |         # Approach 2: If you know it's an older version, start with higher limit
773 |         result = await nixhub_package_versions("ruby", limit=50)
774 |         assert "3.0" in result, "Direct approach with higher limit works"
775 | 
776 |         # This demonstrates why AI should use higher limits for older versions
777 | 
```

--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------

```python
  1 | """Comprehensive test suite for MCP-NixOS server with 100% coverage."""
  2 | 
  3 | from unittest.mock import Mock, patch
  4 | 
  5 | import pytest
  6 | import requests
  7 | from mcp_nixos import server
  8 | from mcp_nixos.server import (
  9 |     DARWIN_URL,
 10 |     HOME_MANAGER_URL,
 11 |     NIXOS_API,
 12 |     NIXOS_AUTH,
 13 |     error,
 14 |     es_query,
 15 |     get_channels,
 16 |     mcp,
 17 |     parse_html_options,
 18 | )
 19 | 
 20 | 
 21 | def get_tool_function(tool_name: str):
 22 |     """Get the underlying function from a FastMCP tool."""
 23 |     tool = getattr(server, tool_name)
 24 |     if hasattr(tool, "fn"):
 25 |         return tool.fn
 26 |     return tool
 27 | 
 28 | 
 29 | # Get the underlying functions for direct use
 30 | darwin_info = get_tool_function("darwin_info")
 31 | darwin_list_options = get_tool_function("darwin_list_options")
 32 | darwin_options_by_prefix = get_tool_function("darwin_options_by_prefix")
 33 | darwin_search = get_tool_function("darwin_search")
 34 | darwin_stats = get_tool_function("darwin_stats")
 35 | home_manager_info = get_tool_function("home_manager_info")
 36 | home_manager_list_options = get_tool_function("home_manager_list_options")
 37 | home_manager_options_by_prefix = get_tool_function("home_manager_options_by_prefix")
 38 | home_manager_search = get_tool_function("home_manager_search")
 39 | home_manager_stats = get_tool_function("home_manager_stats")
 40 | nixos_info = get_tool_function("nixos_info")
 41 | nixos_search = get_tool_function("nixos_search")
 42 | nixos_stats = get_tool_function("nixos_stats")
 43 | 
 44 | 
 45 | class TestHelperFunctions:
 46 |     """Test all helper functions with edge cases."""
 47 | 
 48 |     def test_error_basic(self):
 49 |         """Test basic error formatting."""
 50 |         result = error("Test message")
 51 |         assert result == "Error (ERROR): Test message"
 52 | 
 53 |     def test_error_with_code(self):
 54 |         """Test error formatting with custom code."""
 55 |         result = error("Not found", "NOT_FOUND")
 56 |         assert result == "Error (NOT_FOUND): Not found"
 57 | 
 58 |     def test_error_xml_escaping(self):
 59 |         """Test character escaping in errors."""
 60 |         result = error("Error <tag> & \"quotes\" 'apostrophe'", "CODE")
 61 |         assert result == "Error (CODE): Error <tag> & \"quotes\" 'apostrophe'"
 62 | 
 63 |     def test_error_empty_message(self):
 64 |         """Test error with empty message."""
 65 |         result = error("")
 66 |         assert result == "Error (ERROR): "
 67 | 
 68 |     @patch("mcp_nixos.server.requests.post")
 69 |     def test_es_query_success(self, mock_post):
 70 |         """Test successful Elasticsearch query."""
 71 |         mock_resp = Mock()
 72 |         mock_resp.json.return_value = {"hits": {"hits": [{"_source": {"test": "data"}}]}}
 73 |         mock_post.return_value = mock_resp
 74 | 
 75 |         result = es_query("test-index", {"match_all": {}})
 76 |         assert len(result) == 1
 77 |         assert result[0]["_source"]["test"] == "data"
 78 | 
 79 |         # Verify request parameters
 80 |         mock_post.assert_called_once_with(
 81 |             f"{NIXOS_API}/test-index/_search",
 82 |             json={"query": {"match_all": {}}, "size": 20},
 83 |             auth=NIXOS_AUTH,
 84 |             timeout=10,
 85 |         )
 86 | 
 87 |     @patch("mcp_nixos.server.requests.post")
 88 |     def test_es_query_custom_size(self, mock_post):
 89 |         """Test Elasticsearch query with custom size."""
 90 |         mock_resp = Mock()
 91 |         mock_resp.json.return_value = {"hits": {"hits": []}}
 92 |         mock_post.return_value = mock_resp
 93 | 
 94 |         es_query("test-index", {"match_all": {}}, size=50)
 95 | 
 96 |         # Verify size parameter
 97 |         call_args = mock_post.call_args[1]
 98 |         assert call_args["json"]["size"] == 50
 99 | 
100 |     @patch("mcp_nixos.server.requests.post")
101 |     def test_es_query_http_error(self, mock_post):
102 |         """Test Elasticsearch query with HTTP error."""
103 |         mock_resp = Mock()
104 |         mock_resp.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
105 |         mock_post.return_value = mock_resp
106 | 
107 |         with pytest.raises(Exception, match="API error: 404 Not Found"):
108 |             es_query("test-index", {"match_all": {}})
109 | 
110 |     @patch("mcp_nixos.server.requests.post")
111 |     def test_es_query_connection_error(self, mock_post):
112 |         """Test Elasticsearch query with connection error."""
113 |         mock_post.side_effect = requests.ConnectionError("Connection failed")
114 | 
115 |         with pytest.raises(Exception, match="API error: Connection failed"):
116 |             es_query("test-index", {"match_all": {}})
117 | 
118 |     @patch("mcp_nixos.server.requests.post")
119 |     def test_es_query_missing_hits(self, mock_post):
120 |         """Test Elasticsearch query with missing hits field."""
121 |         mock_resp = Mock()
122 |         mock_resp.json.return_value = {}  # No hits field
123 |         mock_post.return_value = mock_resp
124 | 
125 |         result = es_query("test-index", {"match_all": {}})
126 |         assert result == []
127 | 
128 |     @patch("mcp_nixos.server.requests.get")
129 |     def test_parse_html_options_success(self, mock_get):
130 |         """Test successful HTML parsing."""
131 |         mock_resp = Mock()
132 |         html_content = """
133 |         <html>
134 |             <dt>programs.git.enable</dt>
135 |             <dd>
136 |                 <p>Enable git</p>
137 |                 <span class="term">Type: boolean</span>
138 |             </dd>
139 |             <dt>programs.vim.enable</dt>
140 |             <dd>
141 |                 <p>Enable vim</p>
142 |                 <span class="term">Type: boolean</span>
143 |             </dd>
144 |         </html>
145 |         """
146 |         mock_resp.content = html_content.encode("utf-8")
147 |         mock_resp.raise_for_status = Mock()
148 |         mock_get.return_value = mock_resp
149 | 
150 |         result = parse_html_options("http://test.com")
151 |         assert len(result) == 2
152 |         assert result[0]["name"] == "programs.git.enable"
153 |         assert result[0]["description"] == "Enable git"
154 |         assert result[0]["type"] == "boolean"
155 | 
156 |     @patch("mcp_nixos.server.requests.get")
157 |     def test_parse_html_options_with_query(self, mock_get):
158 |         """Test HTML parsing with query filter."""
159 |         mock_resp = Mock()
160 |         html_content = """
161 |         <html>
162 |             <dt>programs.git.enable</dt>
163 |             <dd><p>Enable git</p></dd>
164 |             <dt>programs.vim.enable</dt>
165 |             <dd><p>Enable vim</p></dd>
166 |         </html>
167 |         """
168 |         mock_resp.content = html_content.encode("utf-8")
169 |         mock_resp.raise_for_status = Mock()
170 |         mock_get.return_value = mock_resp
171 | 
172 |         result = parse_html_options("http://test.com", query="git")
173 |         assert len(result) == 1
174 |         assert result[0]["name"] == "programs.git.enable"
175 | 
176 |     @patch("mcp_nixos.server.requests.get")
177 |     def test_parse_html_options_with_prefix(self, mock_get):
178 |         """Test HTML parsing with prefix filter."""
179 |         mock_resp = Mock()
180 |         html_content = """
181 |         <html>
182 |             <dt>programs.git.enable</dt>
183 |             <dd><p>Enable git</p></dd>
184 |             <dt>services.nginx.enable</dt>
185 |             <dd><p>Enable nginx</p></dd>
186 |         </html>
187 |         """
188 |         mock_resp.content = html_content.encode("utf-8")
189 |         mock_resp.raise_for_status = Mock()
190 |         mock_get.return_value = mock_resp
191 | 
192 |         result = parse_html_options("http://test.com", prefix="programs")
193 |         assert len(result) == 1
194 |         assert result[0]["name"] == "programs.git.enable"
195 | 
196 |     @patch("mcp_nixos.server.requests.get")
197 |     def test_parse_html_options_empty_response(self, mock_get):
198 |         """Test HTML parsing with empty response."""
199 |         mock_resp = Mock()
200 |         mock_resp.content = b"<html></html>"
201 |         mock_resp.raise_for_status = Mock()
202 |         mock_get.return_value = mock_resp
203 | 
204 |         result = parse_html_options("http://test.com")
205 |         assert not result
206 | 
207 |     @patch("mcp_nixos.server.requests.get")
208 |     def test_parse_html_options_connection_error(self, mock_get):
209 |         """Test HTML parsing with connection error."""
210 |         mock_get.side_effect = requests.ConnectionError("Failed to connect")
211 | 
212 |         with pytest.raises(Exception, match="Failed to fetch docs: Failed to connect"):
213 |             parse_html_options("http://test.com")
214 | 
215 |     @patch("mcp_nixos.server.requests.get")
216 |     def test_parse_html_options_limit(self, mock_get):
217 |         """Test HTML parsing with limit."""
218 |         mock_resp = Mock()
219 |         # Create many options
220 |         options_html = ""
221 |         for i in range(10):
222 |             options_html += f"<dt>option.{i}</dt><dd><p>desc{i}</p></dd>"
223 |         mock_resp.content = f"<html>{options_html}</html>".encode()
224 |         mock_resp.raise_for_status = Mock()
225 |         mock_get.return_value = mock_resp
226 | 
227 |         result = parse_html_options("http://test.com", limit=5)
228 |         assert len(result) == 5
229 | 
230 |     @patch("mcp_nixos.server.requests.get")
231 |     def test_parse_html_options_windows_1252_encoding(self, mock_get):
232 |         """Test HTML parsing with windows-1252 encoding."""
233 |         # Create HTML content with special characters
234 |         html_content = """
235 |         <html>
236 |             <head><meta charset="windows-1252"></head>
237 |             <dt>programs.git.userName</dt>
238 |             <dd>
239 |                 <p>Git user name with special chars: café</p>
240 |                 <span class="term">Type: string</span>
241 |             </dd>
242 |         </html>
243 |         """
244 | 
245 |         mock_resp = Mock()
246 |         # Simulate windows-1252 encoded content
247 |         mock_resp.content = html_content.encode("windows-1252")
248 |         mock_resp.encoding = "windows-1252"
249 |         mock_resp.raise_for_status = Mock()
250 |         mock_get.return_value = mock_resp
251 | 
252 |         # Should not raise encoding errors
253 |         result = parse_html_options("http://test.com")
254 |         assert len(result) == 1
255 |         assert result[0]["name"] == "programs.git.userName"
256 |         assert "café" in result[0]["description"]
257 | 
258 |     @patch("mcp_nixos.server.requests.get")
259 |     def test_parse_html_options_utf8_with_bom(self, mock_get):
260 |         """Test HTML parsing with UTF-8 BOM."""
261 |         html_content = """
262 |         <html>
263 |             <dt>programs.neovim.enable</dt>
264 |             <dd>
265 |                 <p>Enable Neovim with unicode: 你好</p>
266 |                 <span class="term">Type: boolean</span>
267 |             </dd>
268 |         </html>
269 |         """
270 | 
271 |         mock_resp = Mock()
272 |         # Add UTF-8 BOM at the beginning
273 |         mock_resp.content = b"\xef\xbb\xbf" + html_content.encode("utf-8")
274 |         mock_resp.encoding = "utf-8-sig"
275 |         mock_resp.raise_for_status = Mock()
276 |         mock_get.return_value = mock_resp
277 | 
278 |         result = parse_html_options("http://test.com")
279 |         assert len(result) == 1
280 |         assert result[0]["name"] == "programs.neovim.enable"
281 |         assert "你好" in result[0]["description"]
282 | 
283 |     @patch("mcp_nixos.server.requests.get")
284 |     def test_parse_html_options_iso_8859_1_encoding(self, mock_get):
285 |         """Test HTML parsing with ISO-8859-1 encoding."""
286 |         html_content = """
287 |         <html>
288 |             <head><meta charset="iso-8859-1"></head>
289 |             <dt>services.nginx.virtualHosts</dt>
290 |             <dd>
291 |                 <p>Nginx config with special: naïve résumé</p>
292 |             </dd>
293 |         </html>
294 |         """
295 | 
296 |         mock_resp = Mock()
297 |         # Simulate ISO-8859-1 encoded content
298 |         mock_resp.content = html_content.encode("iso-8859-1")
299 |         mock_resp.encoding = "iso-8859-1"
300 |         mock_resp.raise_for_status = Mock()
301 |         mock_get.return_value = mock_resp
302 | 
303 |         result = parse_html_options("http://test.com")
304 |         assert len(result) == 1
305 |         assert result[0]["name"] == "services.nginx.virtualHosts"
306 |         assert "naïve" in result[0]["description"]
307 |         assert "résumé" in result[0]["description"]
308 | 
309 | 
310 | class TestNixOSTools:
311 |     """Test all NixOS tools."""
312 | 
313 |     @patch("mcp_nixos.server.es_query")
314 |     @pytest.mark.asyncio
315 |     async def test_nixos_search_packages_success(self, mock_query):
316 |         """Test successful package search."""
317 |         mock_query.return_value = [
318 |             {
319 |                 "_source": {
320 |                     "package_pname": "firefox",
321 |                     "package_pversion": "123.0",
322 |                     "package_description": "A web browser",
323 |                 }
324 |             }
325 |         ]
326 | 
327 |         result = await nixos_search("firefox", search_type="packages", limit=5)
328 |         assert "Found 1 packages matching 'firefox':" in result
329 |         assert "• firefox (123.0)" in result
330 |         assert "  A web browser" in result
331 | 
332 |     @patch("mcp_nixos.server.es_query")
333 |     @pytest.mark.asyncio
334 |     async def test_nixos_search_options_success(self, mock_query):
335 |         """Test successful option search."""
336 |         mock_query.return_value = [
337 |             {
338 |                 "_source": {
339 |                     "option_name": "services.nginx.enable",
340 |                     "option_type": "boolean",
341 |                     "option_description": "Enable nginx",
342 |                 }
343 |             }
344 |         ]
345 | 
346 |         result = await nixos_search("nginx", search_type="options")
347 |         assert "Found 1 options matching 'nginx':" in result
348 |         assert "• services.nginx.enable" in result
349 |         assert "  Type: boolean" in result
350 |         assert "  Enable nginx" in result
351 | 
352 |     @patch("mcp_nixos.server.es_query")
353 |     @pytest.mark.asyncio
354 |     async def test_nixos_search_programs_success(self, mock_query):
355 |         """Test successful program search."""
356 |         mock_query.return_value = [{"_source": {"package_pname": "vim", "package_programs": ["vim", "vi"]}}]
357 | 
358 |         result = await nixos_search("vim", search_type="programs")
359 |         assert "Found 1 programs matching 'vim':" in result
360 |         assert "• vim (provided by vim)" in result
361 | 
362 |     @patch("mcp_nixos.server.es_query")
363 |     @pytest.mark.asyncio
364 |     async def test_nixos_search_empty_results(self, mock_query):
365 |         """Test search with no results."""
366 |         mock_query.return_value = []
367 | 
368 |         result = await nixos_search("nonexistent")
369 |         assert result == "No packages found matching 'nonexistent'"
370 | 
371 |     @pytest.mark.asyncio
372 |     async def test_nixos_search_invalid_type(self):
373 |         """Test search with invalid type."""
374 |         result = await nixos_search("test", search_type="invalid")
375 |         assert result == "Error (ERROR): Invalid type 'invalid'"
376 | 
377 |     @pytest.mark.asyncio
378 |     async def test_nixos_search_invalid_channel(self):
379 |         """Test search with invalid channel."""
380 |         result = await nixos_search("test", channel="invalid")
381 |         assert "Error (ERROR): Invalid channel 'invalid'" in result
382 |         assert "Available channels:" in result
383 | 
384 |     @pytest.mark.asyncio
385 |     async def test_nixos_search_invalid_limit_low(self):
386 |         """Test search with limit too low."""
387 |         result = await nixos_search("test", limit=0)
388 |         assert result == "Error (ERROR): Limit must be 1-100"
389 | 
390 |     @pytest.mark.asyncio
391 |     async def test_nixos_search_invalid_limit_high(self):
392 |         """Test search with limit too high."""
393 |         result = await nixos_search("test", limit=101)
394 |         assert result == "Error (ERROR): Limit must be 1-100"
395 | 
396 |     @patch("mcp_nixos.server.es_query")
397 |     @pytest.mark.asyncio
398 |     async def test_nixos_search_all_channels(self, mock_query):
399 |         """Test search works with all defined channels."""
400 |         mock_query.return_value = []
401 | 
402 |         channels = get_channels()
403 |         for channel in channels:
404 |             result = await nixos_search("test", channel=channel)
405 |             assert result == "No packages found matching 'test'"
406 | 
407 |             # Verify correct index is used
408 |             mock_query.assert_called_with(
409 |                 channels[channel],
410 |                 {
411 |                     "bool": {
412 |                         "must": [{"term": {"type": "package"}}],
413 |                         "should": [
414 |                             {"match": {"package_pname": {"query": "test", "boost": 3}}},
415 |                             {"match": {"package_description": "test"}},
416 |                         ],
417 |                         "minimum_should_match": 1,
418 |                     }
419 |                 },
420 |                 20,
421 |             )
422 | 
423 |     @patch("mcp_nixos.server.es_query")
424 |     @pytest.mark.asyncio
425 |     async def test_nixos_search_exception_handling(self, mock_query):
426 |         """Test search with API exception."""
427 |         mock_query.side_effect = Exception("API failed")
428 | 
429 |         result = await nixos_search("test")
430 |         assert result == "Error (ERROR): API failed"
431 | 
432 |     @patch("mcp_nixos.server.es_query")
433 |     @pytest.mark.asyncio
434 |     async def test_nixos_info_package_found(self, mock_query):
435 |         """Test info when package found."""
436 |         mock_query.return_value = [
437 |             {
438 |                 "_source": {
439 |                     "package_pname": "firefox",
440 |                     "package_pversion": "123.0",
441 |                     "package_description": "A web browser",
442 |                     "package_homepage": ["https://firefox.com"],
443 |                     "package_license_set": ["MPL-2.0"],
444 |                 }
445 |             }
446 |         ]
447 | 
448 |         result = await nixos_info("firefox", type="package")
449 |         assert "Package: firefox" in result
450 |         assert "Version: 123.0" in result
451 |         assert "Description: A web browser" in result
452 |         assert "Homepage: https://firefox.com" in result
453 |         assert "License: MPL-2.0" in result
454 | 
455 |     @patch("mcp_nixos.server.es_query")
456 |     @pytest.mark.asyncio
457 |     async def test_nixos_info_option_found(self, mock_query):
458 |         """Test info when option found."""
459 |         mock_query.return_value = [
460 |             {
461 |                 "_source": {
462 |                     "option_name": "services.nginx.enable",
463 |                     "option_type": "boolean",
464 |                     "option_description": "Enable nginx",
465 |                     "option_default": "false",
466 |                     "option_example": "true",
467 |                 }
468 |             }
469 |         ]
470 | 
471 |         result = await nixos_info("services.nginx.enable", type="option")
472 |         assert "Option: services.nginx.enable" in result
473 |         assert "Type: boolean" in result
474 |         assert "Description: Enable nginx" in result
475 |         assert "Default: false" in result
476 |         assert "Example: true" in result
477 | 
478 |     @patch("mcp_nixos.server.es_query")
479 |     @pytest.mark.asyncio
480 |     async def test_nixos_info_not_found(self, mock_query):
481 |         """Test info when package/option not found."""
482 |         mock_query.return_value = []
483 | 
484 |         result = await nixos_info("nonexistent", type="package")
485 |         assert result == "Error (NOT_FOUND): Package 'nonexistent' not found"
486 | 
487 |     @pytest.mark.asyncio
488 |     async def test_nixos_info_invalid_type(self):
489 |         """Test info with invalid type."""
490 |         result = await nixos_info("test", type="invalid")
491 |         assert result == "Error (ERROR): Type must be 'package' or 'option'"
492 | 
493 |     @patch("mcp_nixos.server.requests.post")
494 |     @pytest.mark.asyncio
495 |     async def test_nixos_stats_success(self, mock_post):
496 |         """Test stats retrieval."""
497 |         # Mock package count
498 |         pkg_resp = Mock()
499 |         pkg_resp.json.return_value = {"count": 95000}
500 | 
501 |         # Mock option count
502 |         opt_resp = Mock()
503 |         opt_resp.json.return_value = {"count": 18000}
504 | 
505 |         mock_post.side_effect = [pkg_resp, opt_resp]
506 | 
507 |         result = await nixos_stats()
508 |         assert "NixOS Statistics for unstable channel:" in result
509 |         assert "• Packages: 95,000" in result
510 |         assert "• Options: 18,000" in result
511 | 
512 |     @pytest.mark.asyncio
513 |     async def test_nixos_stats_invalid_channel(self):
514 |         """Test stats with invalid channel."""
515 |         result = await nixos_stats(channel="invalid")
516 |         assert "Error (ERROR): Invalid channel 'invalid'" in result
517 |         assert "Available channels:" in result
518 | 
519 |     @patch("mcp_nixos.server.requests.post")
520 |     @pytest.mark.asyncio
521 |     async def test_nixos_stats_api_error(self, mock_post):
522 |         """Test stats with API error."""
523 |         mock_post.side_effect = requests.ConnectionError("Failed")
524 | 
525 |         result = await nixos_stats()
526 |         assert result == "Error (ERROR): Failed to retrieve statistics"
527 | 
528 | 
529 | class TestHomeManagerTools:
530 |     """Test all Home Manager tools."""
531 | 
532 |     @patch("mcp_nixos.server.parse_html_options")
533 |     @pytest.mark.asyncio
534 |     async def test_home_manager_search_success(self, mock_parse):
535 |         """Test successful Home Manager search."""
536 |         mock_parse.return_value = [{"name": "programs.git.enable", "type": "boolean", "description": "Enable git"}]
537 | 
538 |         result = await home_manager_search("git")
539 |         assert "Found 1 Home Manager options matching 'git':" in result
540 |         assert "• programs.git.enable" in result
541 |         assert "  Type: boolean" in result
542 |         assert "  Enable git" in result
543 | 
544 |         # Verify parse was called correctly
545 |         mock_parse.assert_called_once_with(HOME_MANAGER_URL, "git", "", 20)
546 | 
547 |     @pytest.mark.asyncio
548 |     async def test_home_manager_search_invalid_limit(self):
549 |         """Test Home Manager search with invalid limit."""
550 |         result = await home_manager_search("test", limit=0)
551 |         assert result == "Error (ERROR): Limit must be 1-100"
552 | 
553 |     @patch("mcp_nixos.server.parse_html_options")
554 |     @pytest.mark.asyncio
555 |     async def test_home_manager_search_exception(self, mock_parse):
556 |         """Test Home Manager search with exception."""
557 |         mock_parse.side_effect = Exception("Parse failed")
558 | 
559 |         result = await home_manager_search("test")
560 |         assert result == "Error (ERROR): Parse failed"
561 | 
562 |     @patch("mcp_nixos.server.parse_html_options")
563 |     @pytest.mark.asyncio
564 |     async def test_home_manager_info_found(self, mock_parse):
565 |         """Test Home Manager info when option found."""
566 |         mock_parse.return_value = [{"name": "programs.git.enable", "type": "boolean", "description": "Enable git"}]
567 | 
568 |         result = await home_manager_info("programs.git.enable")
569 |         assert "Option: programs.git.enable" in result
570 |         assert "Type: boolean" in result
571 |         assert "Description: Enable git" in result
572 | 
573 |     @patch("mcp_nixos.server.parse_html_options")
574 |     @pytest.mark.asyncio
575 |     async def test_home_manager_info_not_found(self, mock_parse):
576 |         """Test Home Manager info when option not found."""
577 |         mock_parse.return_value = [{"name": "programs.vim.enable", "type": "boolean", "description": "Enable vim"}]
578 | 
579 |         result = await home_manager_info("programs.git.enable")
580 |         assert result == (
581 |             "Error (NOT_FOUND): Option 'programs.git.enable' not found.\n"
582 |             "Tip: Use home_manager_options_by_prefix('programs.git.enable') to browse available options."
583 |         )
584 | 
585 |     @patch("requests.get")
586 |     @pytest.mark.asyncio
587 |     async def test_home_manager_stats(self, mock_get):
588 |         """Test Home Manager stats message."""
589 |         mock_html = """
590 |         <html>
591 |         <body>
592 |             <dl class="variablelist">
593 |                 <dt id="opt-programs.git.enable">programs.git.enable</dt>
594 |                 <dd>Enable git</dd>
595 |                 <dt id="opt-services.gpg-agent.enable">services.gpg-agent.enable</dt>
596 |                 <dd>Enable gpg-agent</dd>
597 |             </dl>
598 |         </body>
599 |         </html>
600 |         """
601 |         mock_resp = Mock()
602 |         mock_resp.content = mock_html.encode("utf-8")
603 |         mock_resp.raise_for_status = Mock()
604 |         mock_get.return_value = mock_resp
605 | 
606 |         result = await home_manager_stats()
607 |         assert "Home Manager Statistics:" in result
608 |         assert "Total options:" in result
609 |         assert "Categories:" in result
610 | 
611 |     @patch("mcp_nixos.server.parse_html_options")
612 |     @pytest.mark.asyncio
613 |     async def test_home_manager_list_options_success(self, mock_parse):
614 |         """Test Home Manager list options."""
615 |         mock_parse.return_value = [
616 |             {"name": "programs.git.enable", "type": "", "description": ""},
617 |             {"name": "programs.vim.enable", "type": "", "description": ""},
618 |             {"name": "services.ssh.enable", "type": "", "description": ""},
619 |         ]
620 | 
621 |         result = await home_manager_list_options()
622 |         assert "Home Manager option categories (2 total):" in result
623 |         assert "• programs (2 options)" in result
624 |         assert "• services (1 options)" in result
625 | 
626 |     @patch("mcp_nixos.server.parse_html_options")
627 |     @pytest.mark.asyncio
628 |     async def test_home_manager_options_by_prefix_success(self, mock_parse):
629 |         """Test Home Manager options by prefix."""
630 |         mock_parse.return_value = [
631 |             {"name": "programs.git.enable", "type": "boolean", "description": "Enable git"},
632 |             {"name": "programs.git.userName", "type": "string", "description": "Git user name"},
633 |         ]
634 | 
635 |         result = await home_manager_options_by_prefix("programs.git")
636 |         assert "Home Manager options with prefix 'programs.git' (2 found):" in result
637 |         assert "• programs.git.enable" in result
638 |         assert "• programs.git.userName" in result
639 | 
640 | 
641 | class TestDarwinTools:
642 |     """Test all Darwin tools."""
643 | 
644 |     @patch("mcp_nixos.server.parse_html_options")
645 |     @pytest.mark.asyncio
646 |     async def test_darwin_search_success(self, mock_parse):
647 |         """Test successful Darwin search."""
648 |         mock_parse.return_value = [
649 |             {"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide the dock"}
650 |         ]
651 | 
652 |         result = await darwin_search("dock")
653 |         assert "Found 1 nix-darwin options matching 'dock':" in result
654 |         assert "• system.defaults.dock.autohide" in result
655 | 
656 |     @pytest.mark.asyncio
657 |     async def test_darwin_search_invalid_limit(self):
658 |         """Test Darwin search with invalid limit."""
659 |         result = await darwin_search("test", limit=101)
660 |         assert result == "Error (ERROR): Limit must be 1-100"
661 | 
662 |     @patch("mcp_nixos.server.parse_html_options")
663 |     @pytest.mark.asyncio
664 |     async def test_darwin_info_found(self, mock_parse):
665 |         """Test Darwin info when option found."""
666 |         mock_parse.return_value = [
667 |             {"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide the dock"}
668 |         ]
669 | 
670 |         result = await darwin_info("system.defaults.dock.autohide")
671 |         assert "Option: system.defaults.dock.autohide" in result
672 |         assert "Type: boolean" in result
673 |         assert "Description: Auto-hide the dock" in result
674 | 
675 |     @patch("requests.get")
676 |     @pytest.mark.asyncio
677 |     async def test_darwin_stats(self, mock_get):
678 |         """Test Darwin stats message."""
679 |         mock_html = """
680 |         <html>
681 |         <body>
682 |             <dl>
683 |                 <dt>system.defaults.dock.autohide</dt>
684 |                 <dd>Auto-hide the dock</dd>
685 |                 <dt>services.nix-daemon.enable</dt>
686 |                 <dd>Enable nix-daemon</dd>
687 |             </dl>
688 |         </body>
689 |         </html>
690 |         """
691 |         mock_resp = Mock()
692 |         mock_resp.content = mock_html.encode("utf-8")
693 |         mock_resp.raise_for_status = Mock()
694 |         mock_get.return_value = mock_resp
695 | 
696 |         result = await darwin_stats()
697 |         assert "nix-darwin Statistics:" in result
698 |         assert "Total options:" in result
699 |         assert "Categories:" in result
700 | 
701 |     @patch("mcp_nixos.server.parse_html_options")
702 |     @pytest.mark.asyncio
703 |     async def test_darwin_list_options_success(self, mock_parse):
704 |         """Test Darwin list options."""
705 |         mock_parse.return_value = [
706 |             {"name": "system.defaults.dock.autohide", "type": "", "description": ""},
707 |             {"name": "homebrew.enable", "type": "", "description": ""},
708 |         ]
709 | 
710 |         result = await darwin_list_options()
711 |         assert "nix-darwin option categories (2 total):" in result
712 |         assert "• system (1 options)" in result
713 |         assert "• homebrew (1 options)" in result
714 | 
715 |     @patch("mcp_nixos.server.parse_html_options")
716 |     @pytest.mark.asyncio
717 |     async def test_darwin_options_by_prefix_success(self, mock_parse):
718 |         """Test Darwin options by prefix."""
719 |         mock_parse.return_value = [
720 |             {"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide the dock"}
721 |         ]
722 | 
723 |         result = await darwin_options_by_prefix("system.defaults")
724 |         assert "nix-darwin options with prefix 'system.defaults' (1 found):" in result
725 |         assert "• system.defaults.dock.autohide" in result
726 | 
727 | 
728 | class TestEdgeCases:
729 |     """Test edge cases and error conditions."""
730 | 
731 |     @patch("mcp_nixos.server.es_query")
732 |     @pytest.mark.asyncio
733 |     async def test_empty_search_query(self, mock_query):
734 |         """Test search with empty query."""
735 |         mock_query.return_value = []
736 | 
737 |         result = await nixos_search("")
738 |         assert "No packages found matching ''" in result
739 | 
740 |     @patch("mcp_nixos.server.es_query")
741 |     @pytest.mark.asyncio
742 |     async def test_special_characters_in_query(self, mock_query):
743 |         """Test search with special characters."""
744 |         mock_query.return_value = []
745 | 
746 |         result = await nixos_search("test@#$%")
747 |         assert "No packages found matching 'test@#$%'" in result
748 | 
749 |     @patch("mcp_nixos.server.requests.get")
750 |     def test_malformed_html_response(self, mock_get):
751 |         """Test parsing malformed HTML."""
752 |         mock_resp = Mock()
753 |         mock_resp.content = b"<html><dt>broken"  # Malformed HTML
754 |         mock_resp.raise_for_status = Mock()
755 |         mock_get.return_value = mock_resp
756 | 
757 |         # Should not crash, just return empty or partial results
758 |         result = parse_html_options("http://test.com")
759 |         assert isinstance(result, list)
760 | 
761 |     @patch("mcp_nixos.server.es_query")
762 |     @pytest.mark.asyncio
763 |     async def test_missing_fields_in_response(self, mock_query):
764 |         """Test handling missing fields in API response."""
765 |         mock_query.return_value = [{"_source": {"package_pname": "test"}}]  # Missing version and description
766 | 
767 |         result = await nixos_search("test")
768 |         assert "• test ()" in result  # Should handle missing version gracefully
769 | 
770 |     @patch("mcp_nixos.server.requests.post")
771 |     @pytest.mark.asyncio
772 |     async def test_timeout_handling(self, mock_post):
773 |         """Test handling of request timeouts."""
774 |         mock_post.side_effect = requests.Timeout("Request timed out")
775 | 
776 |         result = await nixos_stats()
777 |         assert "Error (ERROR):" in result
778 | 
779 | 
780 | class TestServerIntegration:
781 |     """Test server module integration."""
782 | 
783 |     def test_mcp_instance_exists(self):
784 |         """Test that mcp instance is properly initialized."""
785 |         assert mcp is not None
786 |         assert hasattr(mcp, "tool")
787 | 
788 |     def test_constants_defined(self):
789 |         """Test that all required constants are defined."""
790 |         assert NIXOS_API == "https://search.nixos.org/backend"
791 |         assert NIXOS_AUTH == ("aWVSALXpZv", "X8gPHnzL52wFEekuxsfQ9cSh")
792 |         assert HOME_MANAGER_URL == "https://nix-community.github.io/home-manager/options.xhtml"
793 |         assert DARWIN_URL == "https://nix-darwin.github.io/nix-darwin/manual/index.html"
794 |         channels = get_channels()
795 |         assert "unstable" in channels
796 |         assert "stable" in channels
797 | 
798 |     def test_all_tools_decorated(self):
799 |         """Test that all tool functions are properly decorated."""
800 |         # Tool functions should be registered with mcp and have underlying functions
801 |         tool_names = [
802 |             "nixos_search",
803 |             "nixos_info",
804 |             "nixos_stats",
805 |             "home_manager_search",
806 |             "home_manager_info",
807 |             "home_manager_stats",
808 |             "home_manager_list_options",
809 |             "home_manager_options_by_prefix",
810 |             "darwin_search",
811 |             "darwin_info",
812 |             "darwin_stats",
813 |             "darwin_list_options",
814 |             "darwin_options_by_prefix",
815 |         ]
816 | 
817 |         for tool_name in tool_names:
818 |             # FastMCP decorates functions, so they should have the original function available
819 |             tool = getattr(server, tool_name)
820 |             assert hasattr(tool, "fn"), f"Tool {tool_name} should have 'fn' attribute"
821 |             assert callable(tool.fn), f"Tool {tool_name}.fn should be callable"
822 | 
```

--------------------------------------------------------------------------------
/tests/test_evals.py:
--------------------------------------------------------------------------------

```python
  1 | """Basic evaluation tests for MCP-NixOS to validate AI usability."""
  2 | 
  3 | from dataclasses import dataclass
  4 | from unittest.mock import Mock, patch
  5 | 
  6 | import pytest
  7 | from mcp_nixos import server
  8 | 
  9 | 
 10 | def get_tool_function(tool_name: str):
 11 |     """Get the underlying function from a FastMCP tool."""
 12 |     tool = getattr(server, tool_name)
 13 |     if hasattr(tool, "fn"):
 14 |         return tool.fn
 15 |     return tool
 16 | 
 17 | 
 18 | # Get the underlying functions for direct use
 19 | darwin_search = get_tool_function("darwin_search")
 20 | darwin_info = get_tool_function("darwin_info")
 21 | darwin_list_options = get_tool_function("darwin_list_options")
 22 | darwin_options_by_prefix = get_tool_function("darwin_options_by_prefix")
 23 | home_manager_search = get_tool_function("home_manager_search")
 24 | home_manager_info = get_tool_function("home_manager_info")
 25 | home_manager_list_options = get_tool_function("home_manager_list_options")
 26 | home_manager_options_by_prefix = get_tool_function("home_manager_options_by_prefix")
 27 | nixos_info = get_tool_function("nixos_info")
 28 | nixos_search = get_tool_function("nixos_search")
 29 | nixos_stats = get_tool_function("nixos_stats")
 30 | 
 31 | 
 32 | # Removed duplicate classes - kept the more comprehensive versions below
 33 | 
 34 | 
 35 | class TestErrorHandlingEvals:
 36 |     """Evaluations for error scenarios."""
 37 | 
 38 |     @pytest.fixture(autouse=True)
 39 |     def mock_channel_validation(self):
 40 |         """Mock channel validation to always pass for 'unstable'."""
 41 |         with patch("mcp_nixos.server.channel_cache") as mock_cache:
 42 |             mock_cache.get_available.return_value = {"unstable": "latest-45-nixos-unstable"}
 43 |             mock_cache.get_resolved.return_value = {"unstable": "latest-45-nixos-unstable"}
 44 |             with patch("mcp_nixos.server.validate_channel") as mock_validate:
 45 |                 mock_validate.return_value = True
 46 |                 yield mock_cache
 47 | 
 48 |     @pytest.mark.asyncio
 49 |     async def test_invalid_channel_error(self):
 50 |         """User specifies invalid channel - should get clear error."""
 51 |         result = await nixos_search("firefox", channel="invalid-channel")
 52 | 
 53 |         # Should get a clear error message
 54 |         assert "Error (ERROR): Invalid channel 'invalid-channel'" in result
 55 | 
 56 |     @patch("mcp_nixos.server.requests.post")
 57 |     @pytest.mark.asyncio
 58 |     async def test_package_not_found(self, mock_post):
 59 |         """User searches for non-existent package."""
 60 |         mock_response = Mock()
 61 |         mock_response.json.return_value = {"hits": {"hits": []}}
 62 |         mock_response.raise_for_status = Mock()
 63 |         mock_post.return_value = mock_response
 64 | 
 65 |         result = await nixos_info("nonexistentpackage", type="package")
 66 | 
 67 |         # Should get informative not found error
 68 |         assert "Error (NOT_FOUND): Package 'nonexistentpackage' not found" in result
 69 | 
 70 | 
 71 | class TestCompleteScenarioEval:
 72 |     """End-to-end scenario evaluation."""
 73 | 
 74 |     @pytest.fixture(autouse=True)
 75 |     def mock_channel_validation(self):
 76 |         """Mock channel validation to always pass for 'unstable'."""
 77 |         with patch("mcp_nixos.server.channel_cache") as mock_cache:
 78 |             mock_cache.get_available.return_value = {"unstable": "latest-45-nixos-unstable"}
 79 |             mock_cache.get_resolved.return_value = {"unstable": "latest-45-nixos-unstable"}
 80 |             with patch("mcp_nixos.server.validate_channel") as mock_validate:
 81 |                 mock_validate.return_value = True
 82 |                 yield mock_cache
 83 | 
 84 |     @patch("mcp_nixos.server.requests.post")
 85 |     @patch("mcp_nixos.server.requests.get")
 86 |     @pytest.mark.asyncio
 87 |     async def test_complete_firefox_installation_flow(self, mock_get, mock_post):
 88 |         """Complete flow: user wants Firefox with specific Home Manager config."""
 89 |         # Step 1: Search for Firefox package
 90 |         search_resp = Mock()
 91 |         search_resp.json.return_value = {
 92 |             "hits": {
 93 |                 "hits": [
 94 |                     {
 95 |                         "_source": {
 96 |                             "package_pname": "firefox",
 97 |                             "package_pversion": "121.0",
 98 |                             "package_description": "A web browser built from Firefox source tree",
 99 |                         }
100 |                     }
101 |                 ]
102 |             }
103 |         }
104 |         search_resp.raise_for_status = Mock()
105 | 
106 |         # Step 2: Get package details
107 |         info_resp = Mock()
108 |         info_resp.json.return_value = {
109 |             "hits": {
110 |                 "hits": [
111 |                     {
112 |                         "_source": {
113 |                             "package_pname": "firefox",
114 |                             "package_pversion": "121.0",
115 |                             "package_description": "A web browser built from Firefox source tree",
116 |                             "package_homepage": ["https://www.mozilla.org/firefox/"],
117 |                             "package_license_set": ["MPL-2.0"],
118 |                         }
119 |                     }
120 |                 ]
121 |             }
122 |         }
123 |         info_resp.raise_for_status = Mock()
124 | 
125 |         # Step 3: Search Home Manager options
126 |         hm_resp = Mock()
127 |         hm_resp.content = b"""
128 |         <html>
129 |             <dt>programs.firefox.enable</dt>
130 |             <dd>
131 |                 <p>Whether to enable Firefox.</p>
132 |                 <span class="term">Type: boolean</span>
133 |             </dd>
134 |         </html>
135 |         """
136 |         hm_resp.raise_for_status = Mock()
137 | 
138 |         mock_post.side_effect = [search_resp, info_resp]
139 |         mock_get.return_value = hm_resp
140 | 
141 |         # Execute the flow
142 |         # 1. Search for Firefox
143 |         result1 = await nixos_search("firefox")
144 |         assert "Found 1 packages matching 'firefox':" in result1
145 |         assert "• firefox (121.0)" in result1
146 | 
147 |         # 2. Get detailed info
148 |         result2 = await nixos_info("firefox")
149 |         assert "Package: firefox" in result2
150 |         assert "Homepage: https://www.mozilla.org/firefox/" in result2
151 | 
152 |         # 3. Check Home Manager options
153 |         result3 = await home_manager_search("firefox")
154 |         assert "• programs.firefox.enable" in result3
155 | 
156 |         # AI should now have all info needed to guide user through installation
157 | 
158 | 
159 | # ===== Content from test_evals_comprehensive.py =====
160 | @dataclass
161 | class EvalScenario:
162 |     """Represents an evaluation scenario."""
163 | 
164 |     name: str
165 |     user_query: str
166 |     expected_tool_calls: list[str]
167 |     success_criteria: list[str]
168 |     description: str = ""
169 | 
170 | 
171 | @dataclass
172 | class EvalResult:
173 |     """Result of running an evaluation."""
174 | 
175 |     scenario: EvalScenario
176 |     passed: bool
177 |     score: float  # 0.0 to 1.0
178 |     tool_calls_made: list[tuple[str, dict, str]]  # (tool_name, args, result)
179 |     criteria_met: dict[str, bool]
180 |     reasoning: str
181 | 
182 | 
183 | class MockAIAssistant:
184 |     """Simulates an AI assistant using the MCP tools."""
185 | 
186 |     def __init__(self):
187 |         self.tool_calls = []
188 | 
189 |     async def process_query(self, query: str) -> list[tuple[str, dict, str]]:
190 |         """Process a user query and return tool calls made."""
191 |         self.tool_calls = []
192 | 
193 |         # Simulate AI decision making based on query
194 |         if ("install" in query.lower() or "get" in query.lower()) and any(
195 |             pkg in query.lower() for pkg in ["vscode", "firefox", "git"]
196 |         ):
197 |             await self._handle_package_installation(query)
198 |         elif ("configure" in query.lower() or "set up" in query.lower()) and "nginx" in query.lower():
199 |             await self._handle_service_configuration(query)
200 |         elif (
201 |             "home manager" in query.lower()
202 |             or "should i configure" in query.lower()
203 |             or ("manage" in query.lower() and "home manager" in query.lower())
204 |         ):
205 |             await self._handle_home_manager_query(query)
206 |         elif "dock" in query.lower() and ("darwin" in query.lower() or "macos" in query.lower()):
207 |             await self._handle_darwin_query(query)
208 |         elif "difference between" in query.lower():
209 |             await self._handle_comparison_query(query)
210 | 
211 |         return self.tool_calls
212 | 
213 |     async def _make_tool_call(self, tool_name: str, **kwargs) -> str:
214 |         """Make a tool call and record it."""
215 |         # Map tool names to actual functions
216 |         tools = {
217 |             "nixos_search": nixos_search,
218 |             "nixos_info": nixos_info,
219 |             "nixos_stats": nixos_stats,
220 |             "home_manager_search": home_manager_search,
221 |             "home_manager_info": home_manager_info,
222 |             "home_manager_list_options": home_manager_list_options,
223 |             "home_manager_options_by_prefix": home_manager_options_by_prefix,
224 |             "darwin_search": darwin_search,
225 |             "darwin_info": darwin_info,
226 |             "darwin_list_options": darwin_list_options,
227 |             "darwin_options_by_prefix": darwin_options_by_prefix,
228 |         }
229 | 
230 |         if tool_name in tools:
231 |             result = await tools[tool_name](**kwargs)
232 |             self.tool_calls.append((tool_name, kwargs, result))
233 |             return result
234 |         return ""
235 | 
236 |     async def _handle_package_installation(self, query: str):
237 |         """Handle package installation queries."""
238 |         # Extract package name
239 |         package = None
240 |         if "vscode" in query.lower():
241 |             package = "vscode"
242 |         elif "firefox" in query.lower():
243 |             package = "firefox"
244 |         elif "git" in query.lower():
245 |             package = "git"
246 | 
247 |         if package:
248 |             # Search for the package
249 |             await self._make_tool_call("nixos_search", query=package, search_type="packages")
250 | 
251 |             # If it's a command, also search programs
252 |             if package == "git":
253 |                 await self._make_tool_call("nixos_search", query=package, search_type="programs")
254 | 
255 |             # Get detailed info
256 |             await self._make_tool_call("nixos_info", name=package, type="package")
257 | 
258 |     async def _handle_service_configuration(self, query: str):
259 |         """Handle service configuration queries."""
260 |         if "nginx" in query.lower():
261 |             # Search for nginx options
262 |             await self._make_tool_call("nixos_search", query="services.nginx", search_type="options")
263 |             # Get specific option info
264 |             await self._make_tool_call("nixos_info", name="services.nginx.enable", type="option")
265 |             await self._make_tool_call("nixos_info", name="services.nginx.virtualHosts", type="option")
266 | 
267 |     async def _handle_home_manager_query(self, query: str):
268 |         """Handle Home Manager related queries."""
269 |         if "git" in query.lower():
270 |             # Search both system and user options
271 |             await self._make_tool_call("nixos_search", query="git", search_type="packages")
272 |             await self._make_tool_call("home_manager_search", query="programs.git")
273 |             await self._make_tool_call("home_manager_info", name="programs.git.enable")
274 |         elif "shell" in query.lower():
275 |             # Handle shell configuration queries
276 |             await self._make_tool_call("home_manager_search", query="programs.zsh")
277 |             await self._make_tool_call("home_manager_info", name="programs.zsh.enable")
278 |             await self._make_tool_call("home_manager_options_by_prefix", option_prefix="programs.zsh")
279 | 
280 |     async def _handle_darwin_query(self, query: str):
281 |         """Handle Darwin/macOS queries."""
282 |         if "dock" in query.lower():
283 |             await self._make_tool_call("darwin_search", query="system.defaults.dock")
284 |             await self._make_tool_call("darwin_info", name="system.defaults.dock.autohide")
285 |             await self._make_tool_call("darwin_options_by_prefix", option_prefix="system.defaults.dock")
286 | 
287 |     async def _handle_comparison_query(self, query: str):
288 |         """Handle package comparison queries."""
289 |         if "firefox" in query.lower():
290 |             await self._make_tool_call("nixos_search", query="firefox", search_type="packages")
291 |             await self._make_tool_call("nixos_info", name="firefox", type="package")
292 |             await self._make_tool_call("nixos_info", name="firefox-esr", type="package")
293 | 
294 | 
295 | class EvalFramework:
296 |     """Framework for running and scoring evaluations."""
297 | 
298 |     def __init__(self):
299 |         self.assistant = MockAIAssistant()
300 | 
301 |     async def run_eval(self, scenario: EvalScenario) -> EvalResult:
302 |         """Run a single evaluation scenario."""
303 |         # Have the assistant process the query
304 |         tool_calls = await self.assistant.process_query(scenario.user_query)
305 | 
306 |         # Check which criteria were met
307 |         criteria_met = self._check_criteria(scenario, tool_calls)
308 | 
309 |         # Calculate score
310 |         score = sum(1 for met in criteria_met.values() if met) / len(criteria_met)
311 |         passed = score >= 0.7  # 70% threshold
312 | 
313 |         # Generate reasoning
314 |         reasoning = self._generate_reasoning(scenario, tool_calls, criteria_met)
315 | 
316 |         return EvalResult(
317 |             scenario=scenario,
318 |             passed=passed,
319 |             score=score,
320 |             tool_calls_made=tool_calls,
321 |             criteria_met=criteria_met,
322 |             reasoning=reasoning,
323 |         )
324 | 
325 |     def _check_criteria(self, scenario: EvalScenario, tool_calls: list[tuple[str, dict, str]]) -> dict[str, bool]:
326 |         """Check which success criteria were met."""
327 |         criteria_met = {}
328 | 
329 |         # Check expected tool calls
330 |         expected_tools = set()
331 |         for expected_call in scenario.expected_tool_calls:
332 |             # Parse expected call (handle "await" prefix)
333 |             if expected_call.startswith("await "):
334 |                 tool_name = expected_call[6:].split("(")[0]  # Skip "await "
335 |             else:
336 |                 tool_name = expected_call.split("(")[0]
337 |             expected_tools.add(tool_name)
338 | 
339 |         actual_tools = {call[0] for call in tool_calls}
340 |         criteria_met["made_expected_tool_calls"] = expected_tools.issubset(actual_tools)
341 | 
342 |         # Check specific criteria based on scenario
343 |         all_results = "\n".join(call[2] for call in tool_calls)
344 | 
345 |         for criterion in scenario.success_criteria:
346 |             if "finds" in criterion and "package" in criterion:
347 |                 # Check if package was found
348 |                 criteria_met[criterion] = any("Found" in call[2] and "packages" in call[2] for call in tool_calls)
349 |             elif "mentions" in criterion:
350 |                 # Check if certain text is mentioned
351 |                 key_term = criterion.split("mentions")[1].strip()
352 |                 criteria_met[criterion] = key_term.lower() in all_results.lower()
353 |             elif "provides" in criterion:
354 |                 # Check if examples/syntax provided
355 |                 criteria_met[criterion] = bool(tool_calls) and len(all_results) > 100
356 |             elif "explains" in criterion:
357 |                 # Check if explanation provided (has meaningful content)
358 |                 criteria_met[criterion] = len(all_results) > 200
359 |             else:
360 |                 # Default: assume met if we have results
361 |                 criteria_met[criterion] = bool(tool_calls)
362 | 
363 |         return criteria_met
364 | 
365 |     def _generate_reasoning(
366 |         self, scenario: EvalScenario, tool_calls: list[tuple[str, dict, str]], criteria_met: dict[str, bool]
367 |     ) -> str:
368 |         """Generate reasoning about the evaluation result."""
369 |         parts = []
370 | 
371 |         # Tool usage
372 |         if tool_calls:
373 |             parts.append(f"Made {len(tool_calls)} tool calls")
374 |         else:
375 |             parts.append("No tool calls made")
376 | 
377 |         # Criteria summary
378 |         met_count = sum(1 for met in criteria_met.values() if met)
379 |         parts.append(f"Met {met_count}/{len(criteria_met)} criteria")
380 | 
381 |         # Specific issues
382 |         for criterion, met in criteria_met.items():
383 |             if not met:
384 |                 parts.append(f"Failed: {criterion}")
385 | 
386 |         return "; ".join(parts)
387 | 
388 | 
389 | class TestPackageDiscoveryEvals:
390 |     """Evaluations for package discovery scenarios."""
391 | 
392 |     def setup_method(self):
393 |         self.framework = EvalFramework()
394 | 
395 |     @patch("mcp_nixos.server.es_query")
396 |     @pytest.mark.asyncio
397 |     async def test_eval_find_vscode_package(self, mock_query):
398 |         """Eval: User wants to install VSCode."""
399 |         # Mock responses
400 |         mock_query.return_value = [
401 |             {
402 |                 "_source": {
403 |                     "package_pname": "vscode",
404 |                     "package_pversion": "1.85.0",
405 |                     "package_description": "Open source code editor by Microsoft",
406 |                 }
407 |             }
408 |         ]
409 | 
410 |         scenario = EvalScenario(
411 |             name="find_vscode",
412 |             user_query="I want to install VSCode on NixOS",
413 |             expected_tool_calls=[
414 |                 "await nixos_search(query='vscode', search_type='packages')",
415 |                 "await nixos_info(name='vscode', type='package')",
416 |             ],
417 |             success_criteria=["finds vscode package", "mentions configuration.nix", "provides installation syntax"],
418 |         )
419 | 
420 |         result = await self.framework.run_eval(scenario)
421 | 
422 |         # Verify evaluation
423 |         assert result.passed
424 |         assert result.score >= 0.7
425 |         assert len(result.tool_calls_made) >= 2
426 |         assert any("vscode" in str(call) for call in result.tool_calls_made)
427 | 
428 |     @patch("mcp_nixos.server.es_query")
429 |     @pytest.mark.asyncio
430 |     async def test_eval_find_git_command(self, mock_query):
431 |         """Eval: User wants git command."""
432 | 
433 |         # Mock different responses for different queries
434 |         def query_side_effect(*args, **kwargs):
435 |             query = args[1]
436 |             if "program" in str(query):
437 |                 return [{"_source": {"package_programs": ["git"], "package_pname": "git"}}]
438 |             return [
439 |                 {
440 |                     "_source": {
441 |                         "package_pname": "git",
442 |                         "package_pversion": "2.43.0",
443 |                         "package_description": "Distributed version control system",
444 |                     }
445 |                 }
446 |             ]
447 | 
448 |         mock_query.side_effect = query_side_effect
449 | 
450 |         scenario = EvalScenario(
451 |             name="find_git_command",
452 |             user_query="How do I get the 'git' command on NixOS?",
453 |             expected_tool_calls=[
454 |                 "await nixos_search(query='git', search_type='programs')",
455 |                 "await nixos_info(name='git', type='package')",
456 |             ],
457 |             success_criteria=[
458 |                 "identifies git package",
459 |                 "explains system vs user installation",
460 |                 "shows both environment.systemPackages and Home Manager options",
461 |             ],
462 |         )
463 | 
464 |         result = await self.framework.run_eval(scenario)
465 | 
466 |         assert result.passed
467 |         assert any("programs" in str(call[1]) for call in result.tool_calls_made)
468 | 
469 |     @patch("mcp_nixos.server.es_query")
470 |     @pytest.mark.asyncio
471 |     async def test_eval_package_comparison(self, mock_query):
472 |         """Eval: User needs to compare packages."""
473 | 
474 |         # Mock responses for firefox variants
475 |         def query_side_effect(*args, **kwargs):
476 |             return [
477 |                 {
478 |                     "_source": {
479 |                         "package": {
480 |                             "pname": "firefox",
481 |                             "version": "120.0",
482 |                             "description": "Mozilla Firefox web browser",
483 |                         }
484 |                     }
485 |                 }
486 |             ]
487 | 
488 |         mock_query.side_effect = query_side_effect
489 | 
490 |         scenario = EvalScenario(
491 |             name="compare_firefox_variants",
492 |             user_query="What's the difference between firefox and firefox-esr?",
493 |             expected_tool_calls=[
494 |                 "await nixos_search(query='firefox', search_type='packages')",
495 |                 "await nixos_info(name='firefox', type='package')",
496 |                 "await nixos_info(name='firefox-esr', type='package')",
497 |             ],
498 |             success_criteria=[
499 |                 "explains ESR vs regular versions",
500 |                 "mentions stability vs features trade-off",
501 |                 "provides configuration examples for both",
502 |             ],
503 |         )
504 | 
505 |         result = await self.framework.run_eval(scenario)
506 | 
507 |         # Check that comparison tools were called
508 |         assert len(result.tool_calls_made) >= 2
509 |         assert any("firefox-esr" in str(call) for call in result.tool_calls_made)
510 | 
511 | 
512 | class TestServiceConfigurationEvals:
513 |     """Evaluations for service configuration scenarios."""
514 | 
515 |     def setup_method(self):
516 |         self.framework = EvalFramework()
517 | 
518 |     @patch("mcp_nixos.server.es_query")
519 |     @pytest.mark.asyncio
520 |     async def test_eval_nginx_setup(self, mock_query):
521 |         """Eval: User wants to set up nginx."""
522 |         mock_query.return_value = [
523 |             {
524 |                 "_source": {
525 |                     "option_name": "services.nginx.enable",
526 |                     "option_type": "boolean",
527 |                     "option_description": "Whether to enable nginx web server",
528 |                 }
529 |             }
530 |         ]
531 | 
532 |         scenario = EvalScenario(
533 |             name="nginx_setup",
534 |             user_query="How do I set up nginx on NixOS to serve static files?",
535 |             expected_tool_calls=[
536 |                 "await nixos_search(query='services.nginx', search_type='options')",
537 |                 "await nixos_info(name='services.nginx.enable', type='option')",
538 |                 "await nixos_info(name='services.nginx.virtualHosts', type='option')",
539 |             ],
540 |             success_criteria=[
541 |                 "enables nginx service",
542 |                 "configures virtual host",
543 |                 "explains directory structure",
544 |                 "mentions firewall configuration",
545 |                 "provides complete configuration.nix example",
546 |             ],
547 |         )
548 | 
549 |         result = await self.framework.run_eval(scenario)
550 | 
551 |         assert len(result.tool_calls_made) >= 2
552 |         assert any("nginx" in call[2] for call in result.tool_calls_made)
553 | 
554 |     @patch("mcp_nixos.server.es_query")
555 |     @pytest.mark.asyncio
556 |     async def test_eval_database_setup(self, mock_query):
557 |         """Eval: User wants PostgreSQL setup."""
558 |         mock_query.return_value = [
559 |             {
560 |                 "_source": {
561 |                     "option": {
562 |                         "option_name": "services.postgresql.enable",
563 |                         "option_type": "boolean",
564 |                         "option_description": "Whether to enable PostgreSQL",
565 |                     }
566 |                 }
567 |             }
568 |         ]
569 | 
570 |         scenario = EvalScenario(
571 |             name="postgresql_setup",
572 |             user_query="Set up PostgreSQL with a database for my app",
573 |             expected_tool_calls=[
574 |                 "await nixos_search(query='services.postgresql', search_type='options')",
575 |                 "await nixos_info(name='services.postgresql.enable', type='option')",
576 |                 "await nixos_info(name='services.postgresql.ensureDatabases', type='option')",
577 |                 "await nixos_info(name='services.postgresql.ensureUsers', type='option')",
578 |             ],
579 |             success_criteria=[
580 |                 "enables postgresql service",
581 |                 "creates database",
582 |                 "sets up user with permissions",
583 |                 "explains connection details",
584 |                 "mentions backup considerations",
585 |             ],
586 |         )
587 | 
588 |         # This scenario would need more complex mocking in real implementation
589 |         # For now, just verify the structure works
590 |         result = await self.framework.run_eval(scenario)
591 |         assert isinstance(result, EvalResult)
592 | 
593 | 
594 | class TestHomeManagerIntegrationEvals:
595 |     """Evaluations for Home Manager vs system configuration."""
596 | 
597 |     def setup_method(self):
598 |         self.framework = EvalFramework()
599 | 
600 |     @patch("mcp_nixos.server.es_query")
601 |     @patch("mcp_nixos.server.parse_html_options")
602 |     @pytest.mark.asyncio
603 |     async def test_eval_user_vs_system_config(self, mock_parse, mock_query):
604 |         """Eval: User confused about where to configure git."""
605 |         # Mock system package
606 |         mock_query.return_value = [
607 |             {
608 |                 "_source": {
609 |                     "package": {
610 |                         "pname": "git",
611 |                         "version": "2.43.0",
612 |                         "description": "Distributed version control system",
613 |                     }
614 |                 }
615 |             }
616 |         ]
617 | 
618 |         # Mock Home Manager options
619 |         mock_parse.return_value = [
620 |             {"name": "programs.git.enable", "type": "boolean", "description": "Enable git"},
621 |             {"name": "programs.git.userName", "type": "string", "description": "Git user name"},
622 |         ]
623 | 
624 |         scenario = EvalScenario(
625 |             name="git_config_location",
626 |             user_query="Should I configure git in NixOS or Home Manager?",
627 |             expected_tool_calls=[
628 |                 "await nixos_search(query='git', search_type='packages')",
629 |                 "await home_manager_search(query='programs.git')",
630 |                 "await home_manager_info(name='programs.git.enable')",
631 |             ],
632 |             success_criteria=[
633 |                 "explains system vs user configuration",
634 |                 "recommends Home Manager for user configs",
635 |                 "shows both approaches",
636 |                 "explains when to use each",
637 |             ],
638 |         )
639 | 
640 |         result = await self.framework.run_eval(scenario)
641 | 
642 |         assert len(result.tool_calls_made) >= 3
643 |         assert any("home_manager" in call[0] for call in result.tool_calls_made)
644 | 
645 |     @patch("mcp_nixos.server.parse_html_options")
646 |     @pytest.mark.asyncio
647 |     async def test_eval_dotfiles_management(self, mock_parse):
648 |         """Eval: User wants to manage shell config."""
649 |         mock_parse.return_value = [
650 |             {"name": "programs.zsh.enable", "type": "boolean", "description": "Enable zsh"},
651 |             {"name": "programs.zsh.oh-my-zsh.enable", "type": "boolean", "description": "Enable Oh My Zsh"},
652 |         ]
653 | 
654 |         scenario = EvalScenario(
655 |             name="shell_config",
656 |             user_query="How do I manage my shell configuration with Home Manager?",
657 |             expected_tool_calls=[
658 |                 "await home_manager_search(query='programs.zsh')",
659 |                 "await home_manager_info(name='programs.zsh.enable')",
660 |                 "await home_manager_options_by_prefix(option_prefix='programs.zsh')",
661 |             ],
662 |             success_criteria=[
663 |                 "enables shell program",
664 |                 "explains configuration options",
665 |                 "mentions aliases and plugins",
666 |                 "provides working example",
667 |             ],
668 |         )
669 | 
670 |         result = await self.framework.run_eval(scenario)
671 | 
672 |         assert any("zsh" in str(call) for call in result.tool_calls_made)
673 | 
674 | 
675 | class TestDarwinPlatformEvals:
676 |     """Evaluations for macOS/nix-darwin scenarios."""
677 | 
678 |     def setup_method(self):
679 |         self.framework = EvalFramework()
680 | 
681 |     @patch("mcp_nixos.server.parse_html_options")
682 |     @pytest.mark.asyncio
683 |     async def test_eval_macos_dock_settings(self, mock_parse):
684 |         """Eval: User wants to configure macOS dock."""
685 |         mock_parse.return_value = [
686 |             {"name": "system.defaults.dock.autohide", "type": "boolean", "description": "Auto-hide dock"},
687 |             {"name": "system.defaults.dock.tilesize", "type": "integer", "description": "Dock icon size"},
688 |         ]
689 | 
690 |         scenario = EvalScenario(
691 |             name="macos_dock_config",
692 |             user_query="How do I configure dock settings with nix-darwin?",
693 |             expected_tool_calls=[
694 |                 "await darwin_search(query='system.defaults.dock')",
695 |                 "await darwin_info(name='system.defaults.dock.autohide')",
696 |                 "await darwin_options_by_prefix(option_prefix='system.defaults.dock')",
697 |             ],
698 |             success_criteria=[
699 |                 "finds dock configuration options",
700 |                 "explains autohide and other settings",
701 |                 "provides darwin-configuration.nix example",
702 |                 "mentions darwin-rebuild command",
703 |             ],
704 |         )
705 | 
706 |         result = await self.framework.run_eval(scenario)
707 | 
708 |         assert len(result.tool_calls_made) >= 2
709 |         assert any("darwin" in call[0] for call in result.tool_calls_made)
710 |         assert any("dock" in str(call) for call in result.tool_calls_made)
711 | 
712 | 
713 | class TestEvalReporting:
714 |     """Test evaluation reporting functionality."""
715 | 
716 |     @pytest.mark.asyncio
717 |     async def test_eval_result_generation(self):
718 |         """Test that eval results are properly generated."""
719 |         scenario = EvalScenario(
720 |             name="test_scenario",
721 |             user_query="Test query",
722 |             expected_tool_calls=["await nixos_search(query='test')"],
723 |             success_criteria=["finds test package"],
724 |         )
725 | 
726 |         result = EvalResult(
727 |             scenario=scenario,
728 |             passed=True,
729 |             score=1.0,
730 |             tool_calls_made=[
731 |                 ("nixos_search", {"query": "test"}, "Found 1 packages matching 'test':\n\n• test (1.0.0)")
732 |             ],
733 |             criteria_met={"finds test package": True},
734 |             reasoning="Made 1 tool calls; Met 1/1 criteria",
735 |         )
736 | 
737 |         assert result.passed
738 |         assert result.score == 1.0
739 |         assert len(result.tool_calls_made) == 1
740 | 
741 |     def test_eval_scoring(self):
742 |         """Test evaluation scoring logic."""
743 |         # Create a scenario with multiple criteria
744 |         EvalScenario(
745 |             name="multi_criteria",
746 |             user_query="Test with multiple criteria",
747 |             expected_tool_calls=[],
748 |             success_criteria=["criterion1", "criterion2", "criterion3"],
749 |         )
750 | 
751 |         # Test partial success
752 |         criteria_met = {"criterion1": True, "criterion2": True, "criterion3": False}
753 | 
754 |         score = sum(1 for met in criteria_met.values() if met) / len(criteria_met)
755 |         assert score == pytest.approx(0.666, rel=0.01)
756 |         assert score < 0.7  # Below passing threshold
757 | 
758 |     def generate_eval_report(self, results: list[EvalResult]) -> str:
759 |         """Generate a report from evaluation results."""
760 |         total = len(results)
761 |         passed = sum(1 for r in results if r.passed)
762 |         avg_score = sum(r.score for r in results) / total if total > 0 else 0
763 | 
764 |         report = f"""# MCP-NixOS Evaluation Report
765 | 
766 | ## Summary
767 | - Total Evaluations: {total}
768 | - Passed: {passed} ({passed / total * 100:.1f}%)
769 | - Average Score: {avg_score:.2f}
770 | 
771 | ## Detailed Results
772 | """
773 | 
774 |         for result in results:
775 |             status = "✅ PASS" if result.passed else "❌ FAIL"
776 |             report += f"\n### {status} {result.scenario.name} (Score: {result.score:.2f})\n"
777 |             report += f"Query: {result.scenario.user_query}\n"
778 |             report += f"Reasoning: {result.reasoning}\n"
779 | 
780 |         return report
781 | 
782 | 
783 | class TestCompleteEvalSuite:
784 |     """Run complete evaluation suite."""
785 | 
786 |     @pytest.mark.integration
787 |     @pytest.mark.asyncio
788 |     async def test_run_all_evals(self):
789 |         """Run all evaluation scenarios and generate report."""
790 |         # This would run all eval scenarios and generate a comprehensive report
791 |         # For brevity, just verify the structure exists
792 | 
793 |         all_scenarios = [
794 |             EvalScenario(
795 |                 name="basic_package_install",
796 |                 user_query="How do I install Firefox?",
797 |                 expected_tool_calls=["await nixos_search(query='firefox')"],
798 |                 success_criteria=["finds firefox package"],
799 |             ),
800 |             EvalScenario(
801 |                 name="service_config",
802 |                 user_query="Configure nginx web server",
803 |                 expected_tool_calls=["await nixos_search(query='nginx', search_type='options')"],
804 |                 success_criteria=["finds nginx options"],
805 |             ),
806 |             EvalScenario(
807 |                 name="home_manager_usage",
808 |                 user_query="Should I use Home Manager for git config?",
809 |                 expected_tool_calls=["await home_manager_search(query='git')"],
810 |                 success_criteria=["recommends Home Manager"],
811 |             ),
812 |         ]
813 | 
814 |         assert len(all_scenarios) >= 3
815 |         assert all(isinstance(s, EvalScenario) for s in all_scenarios)
816 | 
817 | 
818 | if __name__ == "__main__":
819 |     pytest.main([__file__, "-v"])
820 | 
```

--------------------------------------------------------------------------------
/tests/test_channels.py:
--------------------------------------------------------------------------------

```python
  1 | #!/usr/bin/env python3
  2 | """Tests for robust channel handling functionality."""
  3 | 
  4 | from unittest.mock import Mock, patch
  5 | 
  6 | import pytest
  7 | import requests
  8 | from mcp_nixos import server
  9 | from mcp_nixos.server import (
 10 |     channel_cache,
 11 |     get_channel_suggestions,
 12 |     get_channels,
 13 |     validate_channel,
 14 | )
 15 | 
 16 | 
 17 | def get_tool_function(tool_name: str):
 18 |     """Get the underlying function from a FastMCP tool."""
 19 |     tool = getattr(server, tool_name)
 20 |     if hasattr(tool, "fn"):
 21 |         return tool.fn
 22 |     return tool
 23 | 
 24 | 
 25 | # Get the underlying functions for direct use
 26 | nixos_channels = get_tool_function("nixos_channels")
 27 | nixos_info = get_tool_function("nixos_info")
 28 | nixos_search = get_tool_function("nixos_search")
 29 | nixos_stats = get_tool_function("nixos_stats")
 30 | 
 31 | 
 32 | class TestChannelHandling:
 33 |     """Test robust channel handling functionality."""
 34 | 
 35 |     @patch("requests.post")
 36 |     def test_discover_available_channels_success(self, mock_post):
 37 |         """Test successful channel discovery."""
 38 |         # Mock successful responses for some channels (note: 24.11 removed from version list)
 39 |         mock_responses = {
 40 |             "latest-43-nixos-unstable": {"count": 151798},
 41 |             "latest-43-nixos-25.05": {"count": 151698},
 42 |         }
 43 | 
 44 |         def side_effect(url, **kwargs):
 45 |             mock_resp = Mock()
 46 |             for pattern, response in mock_responses.items():
 47 |                 if pattern in url:
 48 |                     mock_resp.status_code = 200
 49 |                     mock_resp.json.return_value = response
 50 |                     return mock_resp
 51 |             # Default to 404 for unknown patterns
 52 |             mock_resp.status_code = 404
 53 |             return mock_resp
 54 | 
 55 |         mock_post.side_effect = side_effect
 56 | 
 57 |         # Clear cache first
 58 |         channel_cache.available_channels = None
 59 | 
 60 |         result = channel_cache.get_available()
 61 | 
 62 |         assert "latest-43-nixos-unstable" in result
 63 |         assert "latest-43-nixos-25.05" in result
 64 |         assert "151,798 documents" in result["latest-43-nixos-unstable"]
 65 | 
 66 |     @patch("requests.post")
 67 |     def test_discover_available_channels_with_cache(self, mock_post):
 68 |         """Test that channel discovery uses cache."""
 69 |         # Set up cache
 70 |         channel_cache.available_channels = {"test": "cached"}
 71 | 
 72 |         result = channel_cache.get_available()
 73 | 
 74 |         # Should return cached result without making API calls
 75 |         assert result == {"test": "cached"}
 76 |         mock_post.assert_not_called()
 77 | 
 78 |     @patch("mcp_nixos.server.get_channels")
 79 |     @patch("requests.post")
 80 |     def test_validate_channel_success(self, mock_post, mock_get_channels):
 81 |         """Test successful channel validation."""
 82 |         mock_get_channels.return_value = {"stable": "latest-43-nixos-25.05"}
 83 | 
 84 |         mock_resp = Mock()
 85 |         mock_resp.status_code = 200
 86 |         mock_resp.json.return_value = {"count": 100000}
 87 |         mock_post.return_value = mock_resp
 88 | 
 89 |         result = validate_channel("stable")
 90 |         assert result is True
 91 | 
 92 |     @patch("mcp_nixos.server.get_channels")
 93 |     def test_validate_channel_failure(self, mock_get_channels):
 94 |         """Test channel validation failure."""
 95 |         mock_get_channels.return_value = {"stable": "latest-43-nixos-25.05"}
 96 | 
 97 |         result = validate_channel("nonexistent")
 98 |         assert result is False
 99 | 
100 |     def test_validate_channel_invalid_name(self):
101 |         """Test validation of channel not in CHANNELS."""
102 |         result = validate_channel("totally-invalid")
103 |         assert result is False
104 | 
105 |     @patch("mcp_nixos.server.get_channels")
106 |     def test_get_channel_suggestions_similar(self, mock_get_channels):
107 |         """Test getting suggestions for similar channel names."""
108 |         # Mock the available channels
109 |         mock_get_channels.return_value = {
110 |             "unstable": "latest-43-nixos-unstable",
111 |             "stable": "latest-43-nixos-25.05",
112 |             "25.05": "latest-43-nixos-25.05",
113 |             "24.11": "latest-43-nixos-24.11",
114 |             "beta": "latest-43-nixos-25.05",
115 |         }
116 | 
117 |         result = get_channel_suggestions("unstabl")
118 |         assert "unstable" in result
119 | 
120 |         result = get_channel_suggestions("24")
121 |         assert "24.11" in result
122 | 
123 |     @patch("mcp_nixos.server.get_channels")
124 |     def test_get_channel_suggestions_fallback(self, mock_get_channels):
125 |         """Test fallback suggestions for completely invalid names."""
126 |         # Mock the available channels
127 |         mock_get_channels.return_value = {
128 |             "unstable": "latest-43-nixos-unstable",
129 |             "stable": "latest-43-nixos-25.05",
130 |             "25.05": "latest-43-nixos-25.05",
131 |             "24.11": "latest-43-nixos-24.11",
132 |             "beta": "latest-43-nixos-25.05",
133 |         }
134 | 
135 |         result = get_channel_suggestions("totally-random-xyz")
136 |         assert "unstable" in result
137 |         assert "stable" in result
138 |         assert "25.05" in result
139 | 
140 |     @patch("mcp_nixos.server.channel_cache.get_available")
141 |     @patch("mcp_nixos.server.channel_cache.get_resolved")
142 |     @pytest.mark.asyncio
143 |     async def test_nixos_channels_tool(self, mock_resolved, mock_discover):
144 |         """Test nixos_channels tool output."""
145 |         mock_discover.return_value = {
146 |             "latest-43-nixos-unstable": "151,798 documents",
147 |             "latest-43-nixos-25.05": "151,698 documents",
148 |             "latest-43-nixos-24.11": "142,034 documents",
149 |         }
150 |         mock_resolved.return_value = {
151 |             "unstable": "latest-43-nixos-unstable",
152 |             "stable": "latest-43-nixos-25.05",
153 |             "25.05": "latest-43-nixos-25.05",
154 |             "24.11": "latest-43-nixos-24.11",
155 |             "beta": "latest-43-nixos-25.05",
156 |         }
157 | 
158 |         result = await nixos_channels()
159 | 
160 |         assert "NixOS Channels" in result  # Match both old and new format
161 |         assert "unstable → latest-43-nixos-unstable" in result or "unstable \u2192 latest-43-nixos-unstable" in result
162 |         assert "stable" in result and "latest-43-nixos-25.05" in result
163 |         assert "✓ Available" in result
164 |         assert "151,798 documents" in result
165 | 
166 |     @patch("mcp_nixos.server.channel_cache.get_available")
167 |     @patch("mcp_nixos.server.channel_cache.get_resolved")
168 |     @pytest.mark.asyncio
169 |     async def test_nixos_channels_with_unavailable(self, mock_resolved, mock_discover):
170 |         """Test nixos_channels tool with some unavailable channels."""
171 |         # Only return some channels as available
172 |         mock_discover.return_value = {"latest-43-nixos-unstable": "151,798 documents"}
173 |         mock_resolved.return_value = {
174 |             "unstable": "latest-43-nixos-unstable",
175 |             "stable": "latest-43-nixos-25.05",  # Not available
176 |             "25.05": "latest-43-nixos-25.05",
177 |         }
178 | 
179 |         # Mock that we're not using fallback (partial availability)
180 |         channel_cache.using_fallback = False
181 | 
182 |         result = await nixos_channels()
183 | 
184 |         assert "✓ Available" in result
185 |         assert "✗ Unavailable" in result or "Fallback" in result
186 | 
187 |     @patch("mcp_nixos.server.channel_cache.get_available")
188 |     @pytest.mark.asyncio
189 |     async def test_nixos_channels_with_extra_discovered(self, mock_discover):
190 |         """Test nixos_channels with extra discovered channels."""
191 |         mock_discover.return_value = {
192 |             "latest-43-nixos-unstable": "151,798 documents",
193 |             "latest-43-nixos-25.05": "151,698 documents",
194 |             "latest-44-nixos-unstable": "152,000 documents",  # New channel
195 |         }
196 | 
197 |         # Mock that we're not using fallback
198 |         channel_cache.using_fallback = False
199 | 
200 |         result = await nixos_channels()
201 | 
202 |         # If not using fallback, should show additional channels
203 |         if not channel_cache.using_fallback:
204 |             assert "Additional available channels:" in result or "latest-44-nixos-unstable" in result
205 | 
206 |     @pytest.mark.asyncio
207 |     async def test_nixos_stats_with_invalid_channel(self):
208 |         """Test nixos_stats with invalid channel shows suggestions."""
209 |         result = await nixos_stats("invalid-channel")
210 | 
211 |         assert "Error (ERROR):" in result
212 |         assert "Invalid channel 'invalid-channel'" in result
213 |         assert "Available channels:" in result
214 | 
215 |     @pytest.mark.asyncio
216 |     async def test_nixos_search_with_invalid_channel(self):
217 |         """Test nixos_search with invalid channel shows suggestions."""
218 |         result = await nixos_search("test", channel="invalid-channel")
219 | 
220 |         assert "Error (ERROR):" in result
221 |         assert "Invalid channel 'invalid-channel'" in result
222 |         assert "Available channels:" in result
223 | 
224 |     @patch("mcp_nixos.server.channel_cache.get_resolved")
225 |     def test_channel_mappings_dynamic(self, mock_resolved):
226 |         """Test that dynamic channel mappings work correctly."""
227 |         # Mock the resolved channels
228 |         mock_resolved.return_value = {
229 |             "stable": "latest-43-nixos-25.05",
230 |             "unstable": "latest-43-nixos-unstable",
231 |             "25.05": "latest-43-nixos-25.05",
232 |             "24.11": "latest-43-nixos-24.11",
233 |             "beta": "latest-43-nixos-25.05",
234 |         }
235 | 
236 |         channels = get_channels()
237 | 
238 |         # Should have basic channels
239 |         assert "stable" in channels
240 |         assert "unstable" in channels
241 | 
242 |         # Stable should point to a valid channel index
243 |         assert channels["stable"].startswith("latest-")
244 |         assert "nixos" in channels["stable"]
245 | 
246 |         # Unstable should point to unstable index
247 |         assert "unstable" in channels["unstable"]
248 | 
249 |     @patch("requests.post")
250 |     def test_discover_channels_handles_exceptions(self, mock_post):
251 |         """Test channel discovery handles network exceptions gracefully."""
252 |         mock_post.side_effect = requests.ConnectionError("Network error")
253 | 
254 |         # Clear cache
255 |         channel_cache.available_channels = None
256 | 
257 |         result = channel_cache.get_available()
258 | 
259 |         # Should return empty dict when all requests fail
260 |         assert result == {}
261 | 
262 |     @patch("requests.post")
263 |     def test_validate_channel_handles_exceptions(self, mock_post):
264 |         """Test channel validation handles exceptions gracefully."""
265 |         mock_post.side_effect = requests.ConnectionError("Network error")
266 | 
267 |         result = validate_channel("stable")
268 |         assert result is False
269 | 
270 |     @patch("mcp_nixos.server.channel_cache.get_available")
271 |     @pytest.mark.asyncio
272 |     async def test_nixos_channels_handles_exceptions(self, mock_discover):
273 |         """Test nixos_channels tool handles exceptions gracefully."""
274 |         mock_discover.side_effect = Exception("Discovery failed")
275 | 
276 |         result = await nixos_channels()
277 |         assert "Error (ERROR):" in result
278 |         assert "Discovery failed" in result
279 | 
280 |     @patch("mcp_nixos.server.get_channels")
281 |     def test_channel_suggestions_for_legacy_channels(self, mock_get_channels):
282 |         """Test suggestions work for legacy channel references."""
283 |         mock_get_channels.return_value = {
284 |             "stable": "latest-43-nixos-25.05",
285 |             "unstable": "latest-43-nixos-unstable",
286 |             "25.05": "latest-43-nixos-25.05",
287 |             "24.11": "latest-43-nixos-24.11",
288 |             "beta": "latest-43-nixos-25.05",
289 |         }
290 | 
291 |         # Test old stable reference
292 |         result = get_channel_suggestions("20.09")
293 |         assert "24.11" in result or "stable" in result
294 | 
295 |         # Test partial version
296 |         result = get_channel_suggestions("25")
297 |         assert "25.05" in result
298 | 
299 |     @patch("requests.post")
300 |     def test_discover_channels_filters_empty_indices(self, mock_post):
301 |         """Test that discovery filters out indices with 0 documents."""
302 | 
303 |         def side_effect(url, **kwargs):
304 |             mock_resp = Mock()
305 |             if "empty-index" in url:
306 |                 mock_resp.status_code = 200
307 |                 mock_resp.json.return_value = {"count": 0}  # Empty index
308 |             else:
309 |                 mock_resp.status_code = 200
310 |                 mock_resp.json.return_value = {"count": 100000}
311 |             return mock_resp
312 | 
313 |         mock_post.side_effect = side_effect
314 | 
315 |         # Clear cache
316 |         channel_cache.available_channels = None
317 | 
318 |         # This should work with the actual test patterns
319 |         result = channel_cache.get_available()
320 | 
321 |         # Should not include any indices with 0 documents
322 |         for _, info in result.items():
323 |             # Check that it doesn't start with "0 documents"
324 |             assert not info.startswith("0 documents")
325 | 
326 | 
327 | # ===== Content from test_dynamic_channels.py =====
328 | class TestDynamicChannelLifecycle:
329 |     """Test dynamic channel detection and lifecycle management."""
330 | 
331 |     def setup_method(self):
332 |         """Clear caches before each test."""
333 |         channel_cache.available_channels = None
334 |         channel_cache.resolved_channels = None
335 | 
336 |     @patch("requests.post")
337 |     def test_channel_discovery_future_proof(self, mock_post):
338 |         """Test discovery works with future NixOS releases."""
339 |         # Simulate future release state
340 |         future_responses = {
341 |             "latest-44-nixos-unstable": {"count": 160000},
342 |             "latest-44-nixos-25.11": {"count": 155000},  # New stable
343 |             "latest-44-nixos-25.05": {"count": 152000},  # Old stable
344 |             "latest-43-nixos-25.05": {"count": 151000},  # Legacy
345 |         }
346 | 
347 |         def side_effect(url, **kwargs):
348 |             mock_resp = Mock()
349 |             for pattern, response in future_responses.items():
350 |                 if pattern in url:
351 |                     mock_resp.status_code = 200
352 |                     mock_resp.json.return_value = response
353 |                     return mock_resp
354 |             mock_resp.status_code = 404
355 |             return mock_resp
356 | 
357 |         mock_post.side_effect = side_effect
358 | 
359 |         # Test discovery
360 |         available = channel_cache.get_available()
361 |         assert "latest-44-nixos-unstable" in available
362 |         assert "latest-44-nixos-25.11" in available
363 | 
364 |         # Test resolution - should pick 25.11 as new stable
365 |         channels = channel_cache.get_resolved()
366 |         assert channels["stable"] == "latest-44-nixos-25.11"
367 |         assert channels["unstable"] == "latest-44-nixos-unstable"
368 |         assert channels["25.11"] == "latest-44-nixos-25.11"
369 |         assert channels["25.05"] == "latest-44-nixos-25.05"
370 | 
371 |     @patch("requests.post")
372 |     def test_stable_detection_by_version_priority(self, mock_post):
373 |         """Test stable detection prioritizes higher version numbers."""
374 |         # Same generation, different versions
375 |         responses = {
376 |             "latest-43-nixos-24.11": {"count": 150000},
377 |             "latest-43-nixos-25.05": {"count": 140000},  # Lower count but higher version
378 |             "latest-43-nixos-unstable": {"count": 155000},
379 |         }
380 | 
381 |         def side_effect(url, **kwargs):
382 |             mock_resp = Mock()
383 |             for pattern, response in responses.items():
384 |                 if pattern in url:
385 |                     mock_resp.status_code = 200
386 |                     mock_resp.json.return_value = response
387 |                     return mock_resp
388 |             mock_resp.status_code = 404
389 |             return mock_resp
390 | 
391 |         mock_post.side_effect = side_effect
392 | 
393 |         channels = channel_cache.get_resolved()
394 |         # Should pick 25.05 despite lower count (higher version)
395 |         assert channels["stable"] == "latest-43-nixos-25.05"
396 | 
397 |     @patch("requests.post")
398 |     def test_stable_detection_by_count_when_same_version(self, mock_post):
399 |         """Test stable detection uses count as tiebreaker."""
400 |         responses = {
401 |             "latest-43-nixos-25.05": {"count": 150000},
402 |             "latest-44-nixos-25.05": {"count": 155000},  # Higher count, same version
403 |             "latest-43-nixos-unstable": {"count": 160000},
404 |         }
405 | 
406 |         def side_effect(url, **kwargs):
407 |             mock_resp = Mock()
408 |             for pattern, response in responses.items():
409 |                 if pattern in url:
410 |                     mock_resp.status_code = 200
411 |                     mock_resp.json.return_value = response
412 |                     return mock_resp
413 |             mock_resp.status_code = 404
414 |             return mock_resp
415 | 
416 |         mock_post.side_effect = side_effect
417 | 
418 |         channels = channel_cache.get_resolved()
419 |         # Should pick higher count for same version
420 |         assert channels["stable"] == "latest-44-nixos-25.05"
421 | 
422 |     @patch("requests.post")
423 |     def test_channel_discovery_handles_no_channels(self, mock_post):
424 |         """Test graceful handling when no channels are available."""
425 |         mock_post.return_value = Mock(status_code=404)
426 | 
427 |         available = channel_cache.get_available()
428 |         assert available == {}
429 | 
430 |         channels = channel_cache.get_resolved()
431 |         # Should use fallback channels when discovery fails
432 |         assert channels != {}
433 |         assert "stable" in channels
434 |         assert "unstable" in channels
435 |         assert channel_cache.using_fallback is True
436 | 
437 |     @patch("requests.post")
438 |     def test_channel_discovery_partial_availability(self, mock_post):
439 |         """Test handling when only some channels are available."""
440 |         responses = {
441 |             "latest-43-nixos-unstable": {"count": 150000},
442 |             # No stable releases available
443 |         }
444 | 
445 |         def side_effect(url, **kwargs):
446 |             mock_resp = Mock()
447 |             for pattern, response in responses.items():
448 |                 if pattern in url:
449 |                     mock_resp.status_code = 200
450 |                     mock_resp.json.return_value = response
451 |                     return mock_resp
452 |             mock_resp.status_code = 404
453 |             return mock_resp
454 | 
455 |         mock_post.side_effect = side_effect
456 | 
457 |         channels = channel_cache.get_resolved()
458 |         assert channels["unstable"] == "latest-43-nixos-unstable"
459 |         assert "stable" not in channels  # No stable release found
460 | 
461 |     @patch("mcp_nixos.server.channel_cache.get_resolved")
462 |     @pytest.mark.asyncio
463 |     async def test_nixos_stats_with_dynamic_channels(self, mock_resolve):
464 |         """Test nixos_stats works with dynamically resolved channels."""
465 |         mock_resolve.return_value = {
466 |             "stable": "latest-44-nixos-25.11",
467 |             "unstable": "latest-44-nixos-unstable",
468 |         }
469 | 
470 |         with patch("requests.post") as mock_post:
471 |             # Mock successful response
472 |             mock_resp = Mock()
473 |             mock_resp.status_code = 200
474 |             mock_resp.json.return_value = {"count": 1000}
475 |             mock_resp.raise_for_status.return_value = None
476 |             mock_post.return_value = mock_resp
477 | 
478 |             # Should work with new stable
479 |             result = await nixos_stats("stable")
480 |             # Should not error and should contain statistics
481 |             assert "NixOS Statistics" in result
482 |             assert "stable" in result
483 |             # Should have made API calls
484 |             assert mock_post.called
485 | 
486 |     @patch("mcp_nixos.server.channel_cache.get_resolved")
487 |     @pytest.mark.asyncio
488 |     async def test_nixos_search_with_dynamic_channels(self, mock_resolve):
489 |         """Test nixos_search works with dynamically resolved channels."""
490 |         mock_resolve.return_value = {
491 |             "stable": "latest-44-nixos-25.11",
492 |             "unstable": "latest-44-nixos-unstable",
493 |         }
494 | 
495 |         with patch("mcp_nixos.server.es_query") as mock_es:
496 |             mock_es.return_value = []
497 | 
498 |             result = await nixos_search("test", channel="stable")
499 |             assert "No packages found" in result
500 | 
501 |     @patch("mcp_nixos.server.channel_cache.get_available")
502 |     @pytest.mark.asyncio
503 |     async def test_nixos_channels_tool_shows_current_stable(self, mock_discover):
504 |         """Test nixos_channels tool clearly shows current stable version."""
505 |         mock_discover.return_value = {
506 |             "latest-44-nixos-25.11": "155,000 documents",
507 |             "latest-44-nixos-unstable": "160,000 documents",
508 |         }
509 | 
510 |         with patch("mcp_nixos.server.channel_cache.get_resolved") as mock_resolve:
511 |             mock_resolve.return_value = {
512 |                 "stable": "latest-44-nixos-25.11",
513 |                 "25.11": "latest-44-nixos-25.11",
514 |                 "unstable": "latest-44-nixos-unstable",
515 |             }
516 | 
517 |             result = await nixos_channels()
518 |             assert "stable (current: 25.11)" in result
519 |             assert "latest-44-nixos-25.11" in result
520 |             assert "dynamically discovered" in result
521 | 
522 |     @pytest.mark.asyncio
523 |     async def test_channel_suggestions_work_with_dynamic_channels(self):
524 |         """Test channel suggestions work with dynamic resolution."""
525 |         with patch("mcp_nixos.server.get_channels") as mock_get:
526 |             mock_get.return_value = {
527 |                 "stable": "latest-44-nixos-25.11",
528 |                 "unstable": "latest-44-nixos-unstable",
529 |                 "25.11": "latest-44-nixos-25.11",
530 |             }
531 | 
532 |             result = await nixos_stats("invalid-channel")
533 |             assert "Available channels:" in result
534 |             assert any(ch in result for ch in ["stable", "unstable"])
535 | 
536 |     @patch("requests.post")
537 |     def test_caching_behavior(self, mock_post):
538 |         """Test that caching works correctly."""
539 |         responses = {
540 |             "latest-43-nixos-unstable": {"count": 150000},
541 |             "latest-43-nixos-25.05": {"count": 145000},
542 |         }
543 | 
544 |         call_count = 0
545 | 
546 |         def side_effect(url, **kwargs):
547 |             nonlocal call_count
548 |             call_count += 1
549 |             mock_resp = Mock()
550 |             for pattern, response in responses.items():
551 |                 if pattern in url:
552 |                     mock_resp.status_code = 200
553 |                     mock_resp.json.return_value = response
554 |                     return mock_resp
555 |             mock_resp.status_code = 404
556 |             return mock_resp
557 | 
558 |         mock_post.side_effect = side_effect
559 | 
560 |         # First call should hit API
561 |         channels1 = get_channels()
562 |         first_call_count = call_count
563 | 
564 |         # Second call should use cache
565 |         channels2 = get_channels()
566 |         second_call_count = call_count
567 | 
568 |         assert channels1 == channels2
569 |         assert second_call_count == first_call_count  # No additional API calls
570 | 
571 |     @patch("requests.post")
572 |     def test_malformed_version_handling(self, mock_post):
573 |         """Test handling of malformed version numbers."""
574 |         responses = {
575 |             "latest-43-nixos-unstable": {"count": 150000},
576 |             "latest-43-nixos-badversion": {"count": 145000},  # Invalid version
577 |             "latest-43-nixos-25.05": {"count": 140000},  # Valid version
578 |         }
579 | 
580 |         def side_effect(url, **kwargs):
581 |             mock_resp = Mock()
582 |             for pattern, response in responses.items():
583 |                 if pattern in url:
584 |                     mock_resp.status_code = 200
585 |                     mock_resp.json.return_value = response
586 |                     return mock_resp
587 |             mock_resp.status_code = 404
588 |             return mock_resp
589 | 
590 |         mock_post.side_effect = side_effect
591 | 
592 |         channels = channel_cache.get_resolved()
593 |         # Should ignore malformed version and use valid one
594 |         assert channels["stable"] == "latest-43-nixos-25.05"
595 |         assert "badversion" not in channels
596 | 
597 |     @patch("requests.post")
598 |     def test_network_error_handling(self, mock_post):
599 |         """Test handling of network errors during discovery."""
600 |         mock_post.side_effect = requests.ConnectionError("Network error")
601 | 
602 |         available = channel_cache.get_available()
603 |         assert available == {}
604 | 
605 |         channels = channel_cache.get_resolved()
606 |         # Should use fallback channels when network fails
607 |         assert channels != {}
608 |         assert "stable" in channels
609 |         assert "unstable" in channels
610 |         assert channel_cache.using_fallback is True
611 | 
612 |     @patch("requests.post")
613 |     def test_zero_document_filtering(self, mock_post):
614 |         """Test that channels with zero documents are filtered out."""
615 |         responses = {
616 |             "latest-43-nixos-unstable": {"count": 150000},
617 |             "latest-43-nixos-25.05": {"count": 0},  # Empty index
618 |             "latest-43-nixos-25.11": {"count": 140000},
619 |         }
620 | 
621 |         def side_effect(url, **kwargs):
622 |             mock_resp = Mock()
623 |             for pattern, response in responses.items():
624 |                 if pattern in url:
625 |                     mock_resp.status_code = 200
626 |                     mock_resp.json.return_value = response
627 |                     return mock_resp
628 |             mock_resp.status_code = 404
629 |             return mock_resp
630 | 
631 |         mock_post.side_effect = side_effect
632 | 
633 |         available = channel_cache.get_available()
634 |         assert "latest-43-nixos-unstable" in available
635 |         assert "latest-43-nixos-25.05" not in available  # Filtered out
636 |         assert "latest-43-nixos-25.11" in available
637 | 
638 |     @patch("requests.post")
639 |     def test_version_comparison_edge_cases(self, mock_post):
640 |         """Test version comparison with edge cases."""
641 |         # Note: 20.09 not in test since it's no longer in version list
642 |         responses = {
643 |             "latest-43-nixos-unstable": {"count": 150000},
644 |             "latest-43-nixos-25.05": {"count": 145000},  # Current
645 |             "latest-43-nixos-30.05": {"count": 140000},  # Future
646 |         }
647 | 
648 |         def side_effect(url, **kwargs):
649 |             mock_resp = Mock()
650 |             for pattern, response in responses.items():
651 |                 if pattern in url:
652 |                     mock_resp.status_code = 200
653 |                     mock_resp.json.return_value = response
654 |                     return mock_resp
655 |             mock_resp.status_code = 404
656 |             return mock_resp
657 | 
658 |         mock_post.side_effect = side_effect
659 | 
660 |         channels = channel_cache.get_resolved()
661 |         # Should pick highest version (30.05)
662 |         assert channels["stable"] == "latest-43-nixos-30.05"
663 |         assert "25.05" in channels
664 |         assert "30.05" in channels
665 | 
666 |     @patch("mcp_nixos.server.channel_cache.get_available")
667 |     def test_beta_alias_behavior(self, mock_discover):
668 |         """Test that beta is always an alias for stable."""
669 |         mock_discover.return_value = {
670 |             "latest-44-nixos-25.11": "155,000 documents",
671 |             "latest-44-nixos-unstable": "160,000 documents",
672 |         }
673 | 
674 |         channels = channel_cache.get_resolved()
675 |         assert "beta" in channels
676 |         assert channels["beta"] == channels["stable"]
677 | 
678 |     @pytest.mark.asyncio
679 |     async def test_integration_with_all_tools(self):
680 |         """Test that all tools work with dynamic channels."""
681 |         with patch("mcp_nixos.server.get_channels") as mock_get:
682 |             mock_get.return_value = {
683 |                 "stable": "latest-44-nixos-25.11",
684 |                 "unstable": "latest-44-nixos-unstable",
685 |             }
686 | 
687 |             with patch("mcp_nixos.server.es_query") as mock_es:
688 |                 mock_es.return_value = []
689 | 
690 |                 with patch("requests.post") as mock_post:
691 |                     # Mock successful response for nixos_stats
692 |                     mock_resp = Mock()
693 |                     mock_resp.status_code = 200
694 |                     mock_resp.json.return_value = {"count": 1000}
695 |                     mock_resp.raise_for_status.return_value = None
696 |                     mock_post.return_value = mock_resp
697 | 
698 |                     # Test all tools that use channels
699 |                     tools_to_test = [
700 |                         lambda: nixos_search("test", channel="stable"),
701 |                         lambda: nixos_info("test", channel="stable"),
702 |                         lambda: nixos_stats("stable"),
703 |                     ]
704 | 
705 |                     for tool in tools_to_test:
706 |                         result = await tool()
707 |                         # Should not error due to channel resolution
708 |                         assert (
709 |                             "Error" not in result
710 |                             or "not found" in result
711 |                             or "No packages found" in result
712 |                             or "NixOS Statistics" in result
713 |                         )
714 | 
715 | 
716 | # ===== Tests for Fallback Channel Behavior (Issue #52 fix) =====
717 | class TestFallbackChannels:
718 |     """Test fallback channel behavior when API discovery fails."""
719 | 
720 |     def setup_method(self):
721 |         """Clear caches before each test."""
722 |         channel_cache.available_channels = None
723 |         channel_cache.resolved_channels = None
724 |         channel_cache.using_fallback = False
725 | 
726 |     @patch("requests.post")
727 |     def test_fallback_when_all_api_calls_fail(self, mock_post):
728 |         """Test that fallback channels are used when all API calls fail."""
729 |         # Simulate complete API failure
730 |         mock_post.side_effect = requests.Timeout("Connection timeout")
731 | 
732 |         channels = channel_cache.get_resolved()
733 | 
734 |         # Should use fallback channels
735 |         assert channel_cache.using_fallback is True
736 |         assert "stable" in channels
737 |         assert "unstable" in channels
738 |         assert "25.05" in channels
739 |         assert "beta" in channels
740 |         assert channels["stable"] == "latest-44-nixos-25.05"
741 | 
742 |     @patch("requests.post")
743 |     def test_fallback_when_api_returns_empty(self, mock_post):
744 |         """Test fallback when API returns empty results."""
745 |         # Mock API returning empty results
746 |         mock_resp = Mock()
747 |         mock_resp.status_code = 200
748 |         mock_resp.json.return_value = {"count": 0}
749 |         mock_post.return_value = mock_resp
750 | 
751 |         channels = channel_cache.get_resolved()
752 | 
753 |         # Should use fallback channels
754 |         assert channel_cache.using_fallback is True
755 |         assert "stable" in channels
756 | 
757 |     @patch("requests.post")
758 |     @pytest.mark.asyncio
759 |     async def test_nixos_search_works_with_fallback(self, mock_post):
760 |         """Test that nixos_search works when using fallback channels."""
761 |         # Simulate API failure for discovery
762 |         mock_post.side_effect = requests.Timeout("Connection timeout")
763 | 
764 |         # Clear cache to force rediscovery
765 |         channel_cache.available_channels = None
766 |         channel_cache.resolved_channels = None
767 | 
768 |         # Mock es_query to return empty results
769 |         with patch("mcp_nixos.server.es_query") as mock_es:
770 |             mock_es.return_value = []
771 | 
772 |             # This should NOT fail with "Invalid channel 'stable'"
773 |             result = await nixos_search("test", channel="stable")
774 | 
775 |             # Should work and return "No packages found" not an error about invalid channel
776 |             assert "Invalid channel" not in result
777 |             assert "No packages found" in result or "Error" not in result
778 | 
779 |     @patch("requests.post")
780 |     @pytest.mark.asyncio
781 |     async def test_nixos_channels_shows_fallback_warning(self, mock_post):
782 |         """Test that nixos_channels shows a warning when using fallback."""
783 |         # Simulate API failure
784 |         mock_post.side_effect = requests.ConnectionError("Network error")
785 | 
786 |         # Clear cache
787 |         channel_cache.available_channels = None
788 |         channel_cache.resolved_channels = None
789 | 
790 |         result = await nixos_channels()
791 | 
792 |         # Should show fallback warning
793 |         assert "WARNING" in result or "fallback" in result.lower()
794 |         assert "stable" in result  # Should still show channels
795 | 
796 |     @patch("mcp_nixos.server.get_channels")
797 |     def test_get_channel_suggestions_works_with_fallback(self, mock_get):
798 |         """Test channel suggestions work when using fallback channels."""
799 |         # Mock fallback channels
800 |         mock_get.return_value = {
801 |             "stable": "latest-44-nixos-25.05",
802 |             "unstable": "latest-44-nixos-unstable",
803 |             "25.05": "latest-44-nixos-25.05",
804 |             "beta": "latest-44-nixos-25.05",
805 |         }
806 | 
807 |         result = get_channel_suggestions("invalid")
808 | 
809 |         # Should provide suggestions from fallback channels
810 |         assert "stable" in result or "unstable" in result
811 | 
812 |     @patch("requests.post")
813 |     def test_no_fallback_when_api_succeeds(self, mock_post):
814 |         """Test that fallback is NOT used when API works correctly."""
815 |         # Mock successful API response
816 |         responses = {
817 |             "latest-44-nixos-unstable": {"count": 150000},
818 |             "latest-44-nixos-25.05": {"count": 145000},
819 |         }
820 | 
821 |         def side_effect(url, **kwargs):
822 |             mock_resp = Mock()
823 |             for pattern, response in responses.items():
824 |                 if pattern in url:
825 |                     mock_resp.status_code = 200
826 |                     mock_resp.json.return_value = response
827 |                     return mock_resp
828 |             mock_resp.status_code = 404
829 |             return mock_resp
830 | 
831 |         mock_post.side_effect = side_effect
832 | 
833 |         channels = channel_cache.get_resolved()
834 | 
835 |         # Should NOT use fallback
836 |         assert channel_cache.using_fallback is False
837 |         assert "stable" in channels
838 | 
839 |     @patch("requests.post")
840 |     @pytest.mark.asyncio
841 |     async def test_all_tools_work_with_fallback(self, mock_post):
842 |         """Test that all channel-based tools work with fallback channels."""
843 |         # Simulate API failure
844 |         mock_post.side_effect = requests.Timeout("Timeout")
845 | 
846 |         # Clear cache
847 |         channel_cache.available_channels = None
848 |         channel_cache.resolved_channels = None
849 | 
850 |         # Mock es_query
851 |         with patch("mcp_nixos.server.es_query") as mock_es:
852 |             mock_es.return_value = []
853 | 
854 |             # Test various tools - none should fail with "Invalid channel"
855 |             result1 = await nixos_search("test", channel="stable")
856 |             assert "Invalid channel" not in result1
857 | 
858 |             result2 = await nixos_info("vim", channel="stable")
859 |             assert "Invalid channel" not in result2
860 | 
861 |             result3 = await nixos_stats("stable")
862 |             # nixos_stats might error, but not due to invalid channel
863 |             if "Error" in result3:
864 |                 assert "Invalid channel" not in result3
865 | 
```
Page 3/4FirstPrevNextLast