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'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'm the AI who actually wrote most of this code while James occasionally typed "looks good" and "fix that bug."
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 "that looks cool" 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 |
```