Merge pull request 'feature/nixpkgs-search' (#5) from feature/nixpkgs-search into master

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-02-04 17:07:30 +00:00
21 changed files with 3184 additions and 118 deletions

View File

@@ -1,15 +1,29 @@
{ {
"mcpServers": { "mcpServers": {
"nixos-options": { "nixpkgs-options": {
"command": "nix", "command": "nix",
"args": [ "args": [
"run", "run",
".", ".#nixpkgs-search",
"--", "--",
"options",
"serve" "serve"
], ],
"env": { "env": {
"NIXOS_OPTIONS_DATABASE": "sqlite://:memory:" "NIXPKGS_SEARCH_DATABASE": "sqlite://:memory:"
}
},
"nixpkgs-packages": {
"command": "nix",
"args": [
"run",
".#nixpkgs-search",
"--",
"packages",
"serve"
],
"env": {
"NIXPKGS_SEARCH_DATABASE": "sqlite://:memory:"
} }
} }
} }

102
CLAUDE.md
View File

@@ -8,15 +8,21 @@ This file provides context for Claude when working on this project.
## MCP Servers ## MCP Servers
### NixOS Options (`nixos-options`) ### Nixpkgs Search (`nixpkgs-search`) - **Primary**
Combined search for NixOS options and Nix packages from nixpkgs. Provides two separate MCP servers:
- **Options server**: Search NixOS configuration options (`nixpkgs-search options serve`)
- **Packages server**: Search Nix packages (`nixpkgs-search packages serve`)
### NixOS Options (`nixos-options`) - Legacy
Search and query NixOS configuration options. Uses nixpkgs as source. Search and query NixOS configuration options. Uses nixpkgs as source.
*Note: Prefer using `nixpkgs-search options` instead.*
### Home Manager Options (`hm-options`) ### Home Manager Options (`hm-options`)
Search and query Home Manager configuration options. Uses home-manager repository as source. Search and query Home Manager configuration options. Uses home-manager repository as source.
Both servers share the same architecture: All servers share the same architecture:
- Full-text search across option names and descriptions - Full-text search across option/package names and descriptions
- Query specific options with type, default, example, and declarations - Query specific options/packages with full metadata
- Index multiple revisions (by git hash or channel name) - Index multiple revisions (by git hash or channel name)
- Fetch module source files - Fetch module source files
- PostgreSQL and SQLite backends - PostgreSQL and SQLite backends
@@ -43,20 +49,22 @@ Both servers share the same architecture:
``` ```
labmcp/ labmcp/
├── cmd/ ├── cmd/
│ ├── nixpkgs-search/
│ │ └── main.go # Combined options+packages CLI (primary)
│ ├── nixos-options/ │ ├── nixos-options/
│ │ └── main.go # NixOS options CLI │ │ └── main.go # NixOS options CLI (legacy)
│ └── hm-options/ │ └── hm-options/
│ └── main.go # Home Manager options CLI │ └── main.go # Home Manager options CLI
├── internal/ ├── internal/
│ ├── database/ │ ├── database/
│ │ ├── interface.go # Store interface │ │ ├── interface.go # Store interface (options + packages)
│ │ ├── schema.go # Schema versioning │ │ ├── schema.go # Schema versioning
│ │ ├── postgres.go # PostgreSQL implementation │ │ ├── postgres.go # PostgreSQL implementation
│ │ ├── sqlite.go # SQLite implementation │ │ ├── sqlite.go # SQLite implementation
│ │ └── *_test.go # Database tests │ │ └── *_test.go # Database tests
│ ├── mcp/ │ ├── mcp/
│ │ ├── server.go # MCP server core + ServerConfig │ │ ├── server.go # MCP server core + ServerConfig + modes
│ │ ├── handlers.go # Tool implementations │ │ ├── handlers.go # Tool implementations (options + packages)
│ │ ├── types.go # Protocol types │ │ ├── types.go # Protocol types
│ │ ├── transport.go # Transport interface │ │ ├── transport.go # Transport interface
│ │ ├── transport_stdio.go # STDIO transport │ │ ├── transport_stdio.go # STDIO transport
@@ -66,14 +74,19 @@ labmcp/
│ ├── options/ │ ├── options/
│ │ └── indexer.go # Shared Indexer interface │ │ └── indexer.go # Shared Indexer interface
│ ├── nixos/ │ ├── nixos/
│ │ ├── indexer.go # Nixpkgs indexing │ │ ├── indexer.go # NixOS options indexing
│ │ ├── parser.go # options.json parsing (shared) │ │ ├── parser.go # options.json parsing
│ │ ├── types.go # Channel aliases, extensions │ │ ├── types.go # Channel aliases, extensions
│ │ └── *_test.go # Indexer tests │ │ └── *_test.go # Indexer tests
── homemanager/ ── homemanager/
├── indexer.go # Home Manager indexing ├── indexer.go # Home Manager indexing
├── types.go # Channel aliases, extensions ├── types.go # Channel aliases, extensions
└── *_test.go # Indexer tests └── *_test.go # Indexer tests
│ └── packages/
│ ├── indexer.go # Nix packages indexing
│ ├── parser.go # nix-env JSON parsing
│ ├── types.go # Package types, channel aliases
│ └── *_test.go # Parser tests
├── nix/ ├── nix/
│ ├── module.nix # NixOS module for nixos-options │ ├── module.nix # NixOS module for nixos-options
│ ├── hm-options-module.nix # NixOS module for hm-options │ ├── hm-options-module.nix # NixOS module for hm-options
@@ -90,7 +103,7 @@ labmcp/
## MCP Tools ## MCP Tools
Both servers provide the same 6 tools: ### Options Servers (nixpkgs-search options, nixos-options, hm-options)
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
@@ -101,6 +114,16 @@ Both servers provide the same 6 tools:
| `list_revisions` | List all indexed revisions | | `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision | | `delete_revision` | Delete an indexed revision |
### Packages Server (nixpkgs-search packages)
| Tool | Description |
|------|-------------|
| `search_packages` | Full-text search across package names and descriptions |
| `get_package` | Get full details for a specific package by attr path |
| `get_file` | Fetch source file contents from nixpkgs |
| `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision |
## Key Implementation Details ## Key Implementation Details
### Database ### Database
@@ -136,7 +159,32 @@ Both servers provide the same 6 tools:
## CLI Commands ## CLI Commands
### nixos-options ### nixpkgs-search (Primary)
```bash
# Options MCP Server
nixpkgs-search options serve # Run options MCP server on STDIO
nixpkgs-search options search <query> # Search options
nixpkgs-search options get <option> # Get option details
# Packages MCP Server
nixpkgs-search packages serve # Run packages MCP server on STDIO
nixpkgs-search packages search <query> # Search packages
nixpkgs-search packages get <attr> # Get package details
# Combined Indexing
nixpkgs-search index <revision> # Index options AND packages
nixpkgs-search index --no-packages <r> # Index options only (faster)
nixpkgs-search index --no-options <r> # Index packages only
nixpkgs-search index --no-files <r> # Skip file indexing
nixpkgs-search index --force <r> # Force re-index
# Shared Commands
nixpkgs-search list # List indexed revisions
nixpkgs-search delete <revision> # Delete indexed revision
nixpkgs-search --version # Show version
```
### nixos-options (Legacy)
```bash ```bash
nixos-options serve # Run MCP server on STDIO (default) nixos-options serve # Run MCP server on STDIO (default)
nixos-options serve --transport http # Run MCP server on HTTP nixos-options serve --transport http # Run MCP server on HTTP
@@ -166,7 +214,7 @@ hm-options --version # Show version
### Channel Aliases ### Channel Aliases
**nixos-options**: `nixos-unstable`, `nixos-stable`, `nixos-24.11`, `nixos-24.05`, etc. **nixpkgs-search/nixos-options**: `nixos-unstable`, `nixos-stable`, `nixos-24.11`, `nixos-24.05`, etc.
**hm-options**: `hm-unstable`, `hm-stable`, `master`, `release-24.11`, `release-24.05`, etc. **hm-options**: `hm-unstable`, `hm-stable`, `master`, `release-24.11`, `release-24.05`, etc.
@@ -175,7 +223,9 @@ hm-options --version # Show version
### Development Workflow ### Development Workflow
- **Always run `go fmt ./...` before committing Go code** - **Always run `go fmt ./...` before committing Go code**
- **Run Go commands using `nix develop -c`** (e.g., `nix develop -c go test ./...`) - **Run Go commands using `nix develop -c`** (e.g., `nix develop -c go test ./...`)
- **Use `nix run` to run binaries** (e.g., `nix run .#nixos-options -- serve`) - **Use `nix run` to run/test binaries** (e.g., `nix run .#nixpkgs-search -- options serve`)
- Do NOT use `go build -o /tmp/...` to test binaries - always use `nix run`
- Remember: modified files must be tracked by git for `nix run` to see them
- File paths in responses should use format `path/to/file.go:123` - File paths in responses should use format `path/to/file.go:123`
### Linting ### Linting
@@ -204,9 +254,10 @@ Version bumps should be done once per feature branch, not per commit. Rules:
- **Major bump** (0.1.0 → 1.0.0): Breaking changes to CLI usage or MCP protocol - **Major bump** (0.1.0 → 1.0.0): Breaking changes to CLI usage or MCP protocol
Version is defined in multiple places that must stay in sync: Version is defined in multiple places that must stay in sync:
- `cmd/nixpkgs-search/main.go`
- `cmd/nixos-options/main.go` - `cmd/nixos-options/main.go`
- `cmd/hm-options/main.go` - `cmd/hm-options/main.go`
- `internal/mcp/server.go` (in `DefaultNixOSConfig` and `DefaultHomeManagerConfig`) - `internal/mcp/server.go` (in `DefaultNixOSConfig`, `DefaultHomeManagerConfig`, `DefaultNixpkgsPackagesConfig`)
- `nix/package.nix` - `nix/package.nix`
### User Preferences ### User Preferences
@@ -230,19 +281,24 @@ nix develop -c go test -bench=. -benchtime=1x -timeout=30m ./internal/homemanage
### Building ### Building
```bash ```bash
# Build with nix # Build with nix
nix build .#nixpkgs-search
nix build .#nixos-options nix build .#nixos-options
nix build .#hm-options nix build .#hm-options
# Run directly # Run directly
nix run .#nixos-options -- serve nix run .#nixpkgs-search -- options serve
nix run .#nixpkgs-search -- packages serve
nix run .#nixpkgs-search -- index nixos-unstable
nix run .#hm-options -- serve nix run .#hm-options -- serve
nix run .#nixos-options -- index nixos-unstable
nix run .#hm-options -- index hm-unstable nix run .#hm-options -- index hm-unstable
``` ```
### Indexing Performance ### Indexing Performance
Indexing operations are slow due to Nix evaluation and file downloads. When running index commands, use appropriate timeouts: Indexing operations are slow due to Nix evaluation and file downloads. When running index commands, use appropriate timeouts:
- **nixos-options**: ~5-6 minutes for `nixos-unstable` (with files) - **nixpkgs-search (full)**: ~15-20 minutes for `nixos-unstable` (options + packages + files)
- **nixpkgs-search (options only)**: ~5-6 minutes with `--no-packages`
- **nixpkgs-search (packages only)**: ~10-15 minutes with `--no-options`
- **hm-options**: ~1-2 minutes for `master` (with files) - **hm-options**: ~1-2 minutes for `master` (with files)
Use `--no-files` flag for faster indexing (~1-2 minutes) if file content lookup isn't needed. Use `--no-files` flag to skip file indexing for faster results.
Use `--no-packages` to index only options (matches legacy behavior).

225
README.md
View File

@@ -4,18 +4,26 @@ A collection of Model Context Protocol (MCP) servers written in Go.
## MCP Servers ## MCP Servers
### NixOS Options (`nixos-options`) ### Nixpkgs Search (`nixpkgs-search`) - Primary
Search and query NixOS configuration options across multiple nixpkgs revisions. Designed to help Claude (and other MCP clients) answer questions about NixOS configuration. Combined search for NixOS options and Nix packages from nixpkgs. Provides two separate MCP servers:
- **Options server**: Search NixOS configuration options (`nixpkgs-search options serve`)
- **Packages server**: Search Nix packages (`nixpkgs-search packages serve`)
Both servers share the same database, allowing you to index once and serve both.
### Home Manager Options (`hm-options`) ### Home Manager Options (`hm-options`)
Search and query Home Manager configuration options across multiple home-manager revisions. Designed to help Claude (and other MCP clients) answer questions about Home Manager configuration. Search and query Home Manager configuration options across multiple home-manager revisions. Designed to help Claude (and other MCP clients) answer questions about Home Manager configuration.
### NixOS Options (`nixos-options`) - Legacy
Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-search` instead, which includes this functionality plus package search.
### Shared Features ### Shared Features
- Full-text search across option names and descriptions - Full-text search across option/package names and descriptions
- Query specific options with type, default, example, and declarations - Query specific options/packages with full metadata
- Index multiple revisions (by git hash or channel name) - Index multiple revisions (by git hash or channel name)
- Fetch module source files - Fetch module source files
- Support for PostgreSQL and SQLite backends - Support for PostgreSQL and SQLite backends
@@ -26,18 +34,18 @@ Search and query Home Manager configuration options across multiple home-manager
```bash ```bash
# Build the packages # Build the packages
nix build git+https://git.t-juice.club/torjus/labmcp#nixos-options nix build git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search
nix build git+https://git.t-juice.club/torjus/labmcp#hm-options nix build git+https://git.t-juice.club/torjus/labmcp#hm-options
# Or run directly # Or run directly
nix run git+https://git.t-juice.club/torjus/labmcp#nixos-options -- --help nix run git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search -- --help
nix run git+https://git.t-juice.club/torjus/labmcp#hm-options -- --help nix run git+https://git.t-juice.club/torjus/labmcp#hm-options -- --help
``` ```
### From Source ### From Source
```bash ```bash
go install git.t-juice.club/torjus/labmcp/cmd/nixos-options@latest go install git.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest
``` ```
@@ -50,11 +58,18 @@ Configure in your MCP client (e.g., Claude Desktop):
```json ```json
{ {
"mcpServers": { "mcpServers": {
"nixos-options": { "nixpkgs-options": {
"command": "nixos-options", "command": "nixpkgs-search",
"args": ["serve"], "args": ["options", "serve"],
"env": { "env": {
"NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db" "NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
}
},
"nixpkgs-packages": {
"command": "nixpkgs-search",
"args": ["packages", "serve"],
"env": {
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
} }
}, },
"hm-options": { "hm-options": {
@@ -73,11 +88,18 @@ Alternatively, if you have Nix installed, you can use the flake directly without
```json ```json
{ {
"mcpServers": { "mcpServers": {
"nixos-options": { "nixpkgs-options": {
"command": "nix", "command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixos-options", "--", "serve"], "args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "options", "serve"],
"env": { "env": {
"NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db" "NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
}
},
"nixpkgs-packages": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "packages", "serve"],
"env": {
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
} }
}, },
"hm-options": { "hm-options": {
@@ -93,20 +115,21 @@ Alternatively, if you have Nix installed, you can use the flake directly without
### As MCP Server (HTTP) ### As MCP Server (HTTP)
Both servers can run over HTTP with Server-Sent Events (SSE) for web-based MCP clients: All servers can run over HTTP with Server-Sent Events (SSE) for web-based MCP clients:
```bash ```bash
# Start HTTP server on default address (127.0.0.1:8080) # Start HTTP server on default address (127.0.0.1:8080)
nixos-options serve --transport http nixpkgs-search options serve --transport http
nixpkgs-search packages serve --transport http
hm-options serve --transport http hm-options serve --transport http
# Custom address and CORS configuration # Custom address and CORS configuration
nixos-options serve --transport http \ nixpkgs-search options serve --transport http \
--http-address 0.0.0.0:8080 \ --http-address 0.0.0.0:8080 \
--allowed-origins https://example.com --allowed-origins https://example.com
# With TLS # With TLS
nixos-options serve --transport http \ nixpkgs-search options serve --transport http \
--tls-cert /path/to/cert.pem \ --tls-cert /path/to/cert.pem \
--tls-key /path/to/key.pem --tls-key /path/to/key.pem
``` ```
@@ -118,53 +141,77 @@ HTTP transport endpoints:
### CLI Examples ### CLI Examples
**Index a revision:** **Index a revision (nixpkgs-search):**
```bash ```bash
# NixOS options - index by channel name # Index both options and packages
nixos-options index nixos-unstable nixpkgs-search index nixos-unstable
# Home Manager options - index by channel name
hm-options index hm-unstable
# Index by git hash # Index by git hash
nixos-options index e6eae2ee2110f3d31110d5c222cd395303343b08 nixpkgs-search index e6eae2ee2110f3d31110d5c222cd395303343b08
# Index options only (faster, skip packages)
nixpkgs-search index --no-packages nixos-unstable
# Index packages only (skip options)
nixpkgs-search index --no-options nixos-unstable
# Index without file contents (faster, disables get_file tool) # Index without file contents (faster, disables get_file tool)
nixos-options index --no-files nixos-unstable nixpkgs-search index --no-files nixos-unstable
```
**Index a revision (hm-options):**
```bash
# Index by channel name
hm-options index hm-unstable
# Index without file contents
hm-options index --no-files release-24.11 hm-options index --no-files release-24.11
``` ```
**List indexed revisions:** **List indexed revisions:**
```bash ```bash
nixos-options list nixpkgs-search list
hm-options list hm-options list
``` ```
**Search for options:** **Search for options:**
```bash ```bash
# NixOS options # NixOS options via nixpkgs-search
nixos-options search nginx nixpkgs-search options search nginx
nixos-options search -n 10 postgresql nixpkgs-search options search -n 10 postgresql
# Home Manager options # Home Manager options
hm-options search git hm-options search git
hm-options search -n 10 neovim hm-options search -n 10 neovim
``` ```
**Get option details:** **Search for packages:**
```bash ```bash
nixos-options get services.nginx.enable nixpkgs-search packages search firefox
nixpkgs-search packages search -n 10 python
# Filter by status
nixpkgs-search packages search --unfree nvidia
nixpkgs-search packages search --broken deprecated-package
```
**Get option/package details:**
```bash
nixpkgs-search options get services.nginx.enable
nixpkgs-search packages get firefox
hm-options get programs.git.enable hm-options get programs.git.enable
``` ```
**Delete an indexed revision:** **Delete an indexed revision:**
```bash ```bash
nixos-options delete nixos-23.11 nixpkgs-search delete nixos-23.11
hm-options delete release-23.11 hm-options delete release-23.11
``` ```
@@ -174,20 +221,21 @@ hm-options delete release-23.11
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `NIXOS_OPTIONS_DATABASE` | Database connection string for nixos-options | `sqlite://nixos-options.db` | | `NIXPKGS_SEARCH_DATABASE` | Database connection string for nixpkgs-search | `sqlite://nixpkgs-search.db` |
| `HM_OPTIONS_DATABASE` | Database connection string for hm-options | `sqlite://hm-options.db` | | `HM_OPTIONS_DATABASE` | Database connection string for hm-options | `sqlite://hm-options.db` |
| `NIXOS_OPTIONS_DATABASE` | Database connection string for nixos-options (legacy) | `sqlite://nixos-options.db` |
### Database Connection Strings ### Database Connection Strings
**SQLite:** **SQLite:**
```bash ```bash
export NIXOS_OPTIONS_DATABASE="sqlite:///path/to/database.db" export NIXPKGS_SEARCH_DATABASE="sqlite:///path/to/database.db"
export NIXOS_OPTIONS_DATABASE="sqlite://:memory:" # In-memory export NIXPKGS_SEARCH_DATABASE="sqlite://:memory:" # In-memory
``` ```
**PostgreSQL:** **PostgreSQL:**
```bash ```bash
export NIXOS_OPTIONS_DATABASE="postgres://user:pass@localhost/nixos_options?sslmode=disable" export NIXPKGS_SEARCH_DATABASE="postgres://user:pass@localhost/nixpkgs_search?sslmode=disable"
``` ```
### Command-Line Flags ### Command-Line Flags
@@ -195,13 +243,14 @@ export NIXOS_OPTIONS_DATABASE="postgres://user:pass@localhost/nixos_options?sslm
The database can also be specified via the `-d` or `--database` flag: The database can also be specified via the `-d` or `--database` flag:
```bash ```bash
nixos-options -d "postgres://localhost/nixos" serve nixpkgs-search -d "postgres://localhost/nixpkgs" options serve
nixpkgs-search -d "sqlite://my.db" index nixos-unstable
hm-options -d "sqlite://my.db" index hm-unstable hm-options -d "sqlite://my.db" index hm-unstable
``` ```
## MCP Tools ## MCP Tools
Both servers provide the following tools: ### Options Servers (nixpkgs-search options, hm-options)
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
@@ -212,11 +261,24 @@ Both servers provide the following tools:
| `list_revisions` | List all indexed revisions | | `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision | | `delete_revision` | Delete an indexed revision |
### Packages Server (nixpkgs-search packages)
| Tool | Description |
|------|-------------|
| `search_packages` | Search for packages by name or description |
| `get_package` | Get full details for a specific package |
| `get_file` | Fetch source file contents from nixpkgs |
| `index_revision` | Index a revision |
| `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision |
## NixOS Modules ## NixOS Modules
NixOS modules are provided for running both MCP servers as systemd services. NixOS modules are provided for running the MCP servers as systemd services.
### nixos-options ### nixpkgs-search (Recommended)
The `nixpkgs-search` module runs two separate MCP servers (options and packages) that share a database:
```nix ```nix
{ {
@@ -226,11 +288,12 @@ NixOS modules are provided for running both MCP servers as systemd services.
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
modules = [ modules = [
labmcp.nixosModules.nixos-options-mcp labmcp.nixosModules.nixpkgs-search-mcp
{ {
services.nixos-options-mcp = { services.nixpkgs-search = {
enable = true; enable = true;
indexOnStart = [ "nixos-unstable" ]; indexOnStart = [ "nixos-unstable" ];
# Both options and packages servers are enabled by default
}; };
} }
]; ];
@@ -239,6 +302,19 @@ NixOS modules are provided for running both MCP servers as systemd services.
} }
``` ```
**Options-only configuration:**
```nix
{
services.nixpkgs-search = {
enable = true;
indexOnStart = [ "nixos-unstable" ];
indexFlags = [ "--no-packages" ]; # Faster indexing
packages.enable = false; # Don't run packages server
};
}
```
### hm-options ### hm-options
```nix ```nix
@@ -262,9 +338,60 @@ NixOS modules are provided for running both MCP servers as systemd services.
} }
``` ```
### nixos-options (Legacy)
```nix
{
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
labmcp.nixosModules.nixos-options-mcp
{
services.nixos-options-mcp = {
enable = true;
indexOnStart = [ "nixos-unstable" ];
};
}
];
};
};
}
```
### Module Options ### Module Options
Both modules have similar options. Shown here for `nixos-options-mcp` (replace with `hm-options-mcp` for Home Manager): #### nixpkgs-search
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enable` | bool | `false` | Enable the service |
| `package` | package | from flake | Package to use |
| `database.type` | enum | `"sqlite"` | `"sqlite"` or `"postgres"` |
| `database.name` | string | `"nixpkgs-search.db"` | SQLite database filename |
| `database.connectionString` | string | `""` | PostgreSQL connection URL (stored in Nix store) |
| `database.connectionStringFile` | path | `null` | Path to file with PostgreSQL connection URL (recommended for secrets) |
| `indexOnStart` | list of string | `[]` | Revisions to index on service start |
| `indexFlags` | list of string | `[]` | Additional flags for indexing (e.g., `["--no-packages"]`) |
| `user` | string | `"nixpkgs-search"` | User to run the service as |
| `group` | string | `"nixpkgs-search"` | Group to run the service as |
| `dataDir` | path | `/var/lib/nixpkgs-search` | Directory for data storage |
| `options.enable` | bool | `true` | Enable the options MCP server |
| `options.http.address` | string | `"127.0.0.1:8082"` | HTTP listen address for options server |
| `options.openFirewall` | bool | `false` | Open firewall for options HTTP port |
| `packages.enable` | bool | `true` | Enable the packages MCP server |
| `packages.http.address` | string | `"127.0.0.1:8083"` | HTTP listen address for packages server |
| `packages.openFirewall` | bool | `false` | Open firewall for packages HTTP port |
Both `options.http` and `packages.http` also support:
- `endpoint` (default: `"/mcp"`)
- `allowedOrigins` (default: `[]`)
- `sessionTTL` (default: `"30m"`)
- `tls.enable`, `tls.certFile`, `tls.keyFile`
#### hm-options-mcp / nixos-options-mcp (Legacy)
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
@@ -293,18 +420,18 @@ Using `connectionStringFile` (recommended for production with sensitive credenti
```nix ```nix
{ {
services.nixos-options-mcp = { services.nixpkgs-search = {
enable = true; enable = true;
database = { database = {
type = "postgres"; type = "postgres";
# File contains: postgres://user:secret@localhost/nixos_options?sslmode=disable # File contains: postgres://user:secret@localhost/nixpkgs_search?sslmode=disable
connectionStringFile = "/run/secrets/nixos-options-db"; connectionStringFile = "/run/secrets/nixpkgs-search-db";
}; };
indexOnStart = [ "nixos-unstable" ]; indexOnStart = [ "nixos-unstable" ];
}; };
# Example with agenix or sops-nix for secret management # Example with agenix or sops-nix for secret management
# age.secrets.nixos-options-db.file = ./secrets/nixos-options-db.age; # age.secrets.nixpkgs-search-db.file = ./secrets/nixpkgs-search-db.age;
} }
``` ```
@@ -321,7 +448,7 @@ go test ./...
go test -bench=. ./internal/database/... go test -bench=. ./internal/database/...
# Build # Build
go build ./cmd/nixos-options go build ./cmd/nixpkgs-search
go build ./cmd/hm-options go build ./cmd/hm-options
``` ```

View File

@@ -14,7 +14,7 @@
## New MCP Servers ## New MCP Servers
- [ ] `nixpkgs-packages` - Index and search nixpkgs packages (similar architecture to nixos-options, but for packages instead of options) - [x] `nixpkgs-packages` - Index and search nixpkgs packages (implemented in `nixpkgs-search packages`)
## Nice to Have ## Nice to Have

View File

@@ -20,7 +20,7 @@ import (
const ( const (
defaultDatabase = "sqlite://hm-options.db" defaultDatabase = "sqlite://hm-options.db"
version = "0.1.2" version = "0.2.0"
) )
func main() { func main() {

View File

@@ -19,7 +19,7 @@ import (
const ( const (
defaultDatabase = "sqlite://nixos-options.db" defaultDatabase = "sqlite://nixos-options.db"
version = "0.1.2" version = "0.2.0"
) )
func main() { func main() {

862
cmd/nixpkgs-search/main.go Normal file
View File

@@ -0,0 +1,862 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/nixos"
"git.t-juice.club/torjus/labmcp/internal/packages"
)
const (
defaultDatabase = "sqlite://nixpkgs-search.db"
version = "0.2.0"
)
func main() {
app := &cli.App{
Name: "nixpkgs-search",
Usage: "Search nixpkgs options and packages",
Version: version,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "database",
Aliases: []string{"d"},
Usage: "Database connection string (postgres://... or sqlite://...)",
EnvVars: []string{"NIXPKGS_SEARCH_DATABASE"},
Value: defaultDatabase,
},
},
Commands: []*cli.Command{
optionsCommand(),
packagesCommand(),
indexCommand(),
listCommand(),
deleteCommand(),
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
// optionsCommand returns the options subcommand.
func optionsCommand() *cli.Command {
return &cli.Command{
Name: "options",
Usage: "NixOS options commands",
Subcommands: []*cli.Command{
{
Name: "serve",
Usage: "Run MCP server for NixOS options",
Flags: serveFlags(),
Action: func(c *cli.Context) error {
return runOptionsServe(c)
},
},
{
Name: "search",
Usage: "Search for options",
ArgsUsage: "<query>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "revision",
Aliases: []string{"r"},
Usage: "Revision to search (default: most recent)",
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"n"},
Usage: "Maximum number of results",
Value: 20,
},
},
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("query argument required")
}
return runOptionsSearch(c, c.Args().First())
},
},
{
Name: "get",
Usage: "Get details for a specific option",
ArgsUsage: "<option-name>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "revision",
Aliases: []string{"r"},
Usage: "Revision to search (default: most recent)",
},
},
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("option name required")
}
return runOptionsGet(c, c.Args().First())
},
},
},
}
}
// packagesCommand returns the packages subcommand.
func packagesCommand() *cli.Command {
return &cli.Command{
Name: "packages",
Usage: "Nix packages commands",
Subcommands: []*cli.Command{
{
Name: "serve",
Usage: "Run MCP server for Nix packages",
Flags: serveFlags(),
Action: func(c *cli.Context) error {
return runPackagesServe(c)
},
},
{
Name: "search",
Usage: "Search for packages",
ArgsUsage: "<query>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "revision",
Aliases: []string{"r"},
Usage: "Revision to search (default: most recent)",
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"n"},
Usage: "Maximum number of results",
Value: 20,
},
&cli.BoolFlag{
Name: "broken",
Usage: "Include broken packages only",
},
&cli.BoolFlag{
Name: "unfree",
Usage: "Include unfree packages only",
},
},
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("query argument required")
}
return runPackagesSearch(c, c.Args().First())
},
},
{
Name: "get",
Usage: "Get details for a specific package",
ArgsUsage: "<attr-path>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "revision",
Aliases: []string{"r"},
Usage: "Revision to search (default: most recent)",
},
},
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("attr path required")
}
return runPackagesGet(c, c.Args().First())
},
},
},
}
}
// indexCommand returns the index command (indexes both options and packages).
func indexCommand() *cli.Command {
return &cli.Command{
Name: "index",
Usage: "Index a nixpkgs revision (options and packages)",
ArgsUsage: "<revision>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "no-files",
Usage: "Skip indexing file contents",
},
&cli.BoolFlag{
Name: "no-packages",
Usage: "Skip indexing packages (options only)",
},
&cli.BoolFlag{
Name: "no-options",
Usage: "Skip indexing options (packages only)",
},
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "Force re-indexing even if revision already exists",
},
},
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("revision argument required")
}
return runIndex(c, c.Args().First())
},
}
}
// listCommand returns the list command.
func listCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List indexed revisions",
Action: func(c *cli.Context) error {
return runList(c)
},
}
}
// deleteCommand returns the delete command.
func deleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete an indexed revision",
ArgsUsage: "<revision>",
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("revision argument required")
}
return runDelete(c, c.Args().First())
},
}
}
// serveFlags returns common flags for serve commands.
func serveFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "transport",
Aliases: []string{"t"},
Usage: "Transport type: 'stdio' or 'http'",
Value: "stdio",
},
&cli.StringFlag{
Name: "http-address",
Usage: "HTTP listen address",
Value: "127.0.0.1:8080",
},
&cli.StringFlag{
Name: "http-endpoint",
Usage: "HTTP endpoint path",
Value: "/mcp",
},
&cli.StringSliceFlag{
Name: "allowed-origins",
Usage: "Allowed Origin headers for CORS (can be specified multiple times)",
},
&cli.StringFlag{
Name: "tls-cert",
Usage: "TLS certificate file",
},
&cli.StringFlag{
Name: "tls-key",
Usage: "TLS key file",
},
&cli.DurationFlag{
Name: "session-ttl",
Usage: "Session TTL for HTTP transport",
Value: 30 * time.Minute,
},
}
}
// openStore opens a database store based on the connection string.
func openStore(connStr string) (database.Store, error) {
if strings.HasPrefix(connStr, "sqlite://") {
path := strings.TrimPrefix(connStr, "sqlite://")
return database.NewSQLiteStore(path)
}
if strings.HasPrefix(connStr, "postgres://") || strings.HasPrefix(connStr, "postgresql://") {
return database.NewPostgresStore(connStr)
}
// Default to SQLite with the connection string as path
return database.NewSQLiteStore(connStr)
}
func runOptionsServe(c *cli.Context) error {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags)
config := mcp.DefaultNixOSConfig()
server := mcp.NewServer(store, logger, config)
indexer := nixos.NewIndexer(store)
server.RegisterHandlers(indexer)
transport := c.String("transport")
switch transport {
case "stdio":
logger.Println("Starting NixOS options MCP server on stdio...")
return server.Run(ctx, os.Stdin, os.Stdout)
case "http":
httpConfig := mcp.HTTPConfig{
Address: c.String("http-address"),
Endpoint: c.String("http-endpoint"),
AllowedOrigins: c.StringSlice("allowed-origins"),
SessionTTL: c.Duration("session-ttl"),
TLSCertFile: c.String("tls-cert"),
TLSKeyFile: c.String("tls-key"),
}
httpTransport := mcp.NewHTTPTransport(server, httpConfig)
return httpTransport.Run(ctx)
default:
return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport)
}
}
func runPackagesServe(c *cli.Context) error {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags)
config := mcp.DefaultNixpkgsPackagesConfig()
server := mcp.NewServer(store, logger, config)
pkgIndexer := packages.NewIndexer(store)
server.RegisterPackageHandlers(pkgIndexer)
transport := c.String("transport")
switch transport {
case "stdio":
logger.Println("Starting nixpkgs packages MCP server on stdio...")
return server.Run(ctx, os.Stdin, os.Stdout)
case "http":
httpConfig := mcp.HTTPConfig{
Address: c.String("http-address"),
Endpoint: c.String("http-endpoint"),
AllowedOrigins: c.StringSlice("allowed-origins"),
SessionTTL: c.Duration("session-ttl"),
TLSCertFile: c.String("tls-cert"),
TLSKeyFile: c.String("tls-key"),
}
httpTransport := mcp.NewHTTPTransport(server, httpConfig)
return httpTransport.Run(ctx)
default:
return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport)
}
}
func runIndex(c *cli.Context, revision string) error {
ctx := context.Background()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
indexFiles := !c.Bool("no-files")
indexOptions := !c.Bool("no-options")
indexPackages := !c.Bool("no-packages")
force := c.Bool("force")
optionsIndexer := nixos.NewIndexer(store)
pkgIndexer := packages.NewIndexer(store)
// Resolve revision
ref := optionsIndexer.ResolveRevision(revision)
fmt.Printf("Indexing revision: %s\n", revision)
var optionCount, packageCount, fileCount int
var rev *database.Revision
// Index options first (creates the revision record)
if indexOptions {
var result *nixos.IndexResult
if force {
result, err = optionsIndexer.ReindexRevision(ctx, revision)
} else {
result, err = optionsIndexer.IndexRevision(ctx, revision)
}
if err != nil {
return fmt.Errorf("options indexing failed: %w", err)
}
if result.AlreadyIndexed && !force {
fmt.Printf("Revision already indexed (%d options). Use --force to re-index.\n", result.OptionCount)
rev = result.Revision
} else {
optionCount = result.OptionCount
rev = result.Revision
fmt.Printf("Indexed %d options\n", optionCount)
}
} else {
// If not indexing options, check if revision exists
rev, err = store.GetRevision(ctx, ref)
if err != nil {
return fmt.Errorf("failed to get revision: %w", err)
}
if rev == nil {
// Create revision record without options
commitDate, _ := pkgIndexer.GetCommitDate(ctx, ref)
rev = &database.Revision{
GitHash: ref,
ChannelName: pkgIndexer.GetChannelName(revision),
CommitDate: commitDate,
}
if err := store.CreateRevision(ctx, rev); err != nil {
return fmt.Errorf("failed to create revision: %w", err)
}
}
}
// Index files
if indexFiles && rev != nil {
fmt.Println("Indexing files...")
fileCount, err = optionsIndexer.IndexFiles(ctx, rev.ID, rev.GitHash)
if err != nil {
fmt.Printf("Warning: file indexing failed: %v\n", err)
} else {
fmt.Printf("Indexed %d files\n", fileCount)
}
}
// Index packages
if indexPackages && rev != nil {
fmt.Println("Indexing packages...")
pkgResult, err := pkgIndexer.IndexPackages(ctx, rev.ID, rev.GitHash)
if err != nil {
fmt.Printf("Warning: package indexing failed: %v\n", err)
} else {
packageCount = pkgResult.PackageCount
fmt.Printf("Indexed %d packages\n", packageCount)
}
}
// Summary
fmt.Println()
fmt.Printf("Git hash: %s\n", rev.GitHash)
if rev.ChannelName != "" {
fmt.Printf("Channel: %s\n", rev.ChannelName)
}
if optionCount > 0 {
fmt.Printf("Options: %d\n", optionCount)
}
if packageCount > 0 {
fmt.Printf("Packages: %d\n", packageCount)
}
if fileCount > 0 {
fmt.Printf("Files: %d\n", fileCount)
}
return nil
}
func runList(c *cli.Context) error {
ctx := context.Background()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
revisions, err := store.ListRevisions(ctx)
if err != nil {
return fmt.Errorf("failed to list revisions: %w", err)
}
if len(revisions) == 0 {
fmt.Println("No revisions indexed.")
fmt.Println("Use 'nixpkgs-search index <revision>' to index a nixpkgs version.")
return nil
}
fmt.Printf("Indexed revisions (%d):\n\n", len(revisions))
for _, rev := range revisions {
fmt.Printf(" %s", rev.GitHash[:12])
if rev.ChannelName != "" {
fmt.Printf(" (%s)", rev.ChannelName)
}
fmt.Printf("\n Options: %d, Packages: %d, Indexed: %s\n",
rev.OptionCount, rev.PackageCount, rev.IndexedAt.Format("2006-01-02 15:04"))
}
return nil
}
func runDelete(c *cli.Context, revision string) error {
ctx := context.Background()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Find revision
rev, err := store.GetRevision(ctx, revision)
if err != nil {
return fmt.Errorf("failed to get revision: %w", err)
}
if rev == nil {
rev, err = store.GetRevisionByChannel(ctx, revision)
if err != nil {
return fmt.Errorf("failed to get revision: %w", err)
}
}
if rev == nil {
return fmt.Errorf("revision '%s' not found", revision)
}
if err := store.DeleteRevision(ctx, rev.ID); err != nil {
return fmt.Errorf("failed to delete revision: %w", err)
}
fmt.Printf("Deleted revision %s\n", rev.GitHash)
return nil
}
// Options search and get functions
func runOptionsSearch(c *cli.Context, query string) error {
ctx := context.Background()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
rev, err := resolveRevision(ctx, store, c.String("revision"))
if err != nil {
return err
}
if rev == nil {
return fmt.Errorf("no indexed revision found")
}
filters := database.SearchFilters{
Limit: c.Int("limit"),
}
options, err := store.SearchOptions(ctx, rev.ID, query, filters)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(options) == 0 {
fmt.Printf("No options found matching '%s'\n", query)
return nil
}
fmt.Printf("Found %d options matching '%s':\n\n", len(options), query)
for _, opt := range options {
fmt.Printf(" %s\n", opt.Name)
fmt.Printf(" Type: %s\n", opt.Type)
if opt.Description != "" {
desc := opt.Description
if len(desc) > 100 {
desc = desc[:100] + "..."
}
fmt.Printf(" %s\n", desc)
}
fmt.Println()
}
return nil
}
func runOptionsGet(c *cli.Context, name string) error {
ctx := context.Background()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
rev, err := resolveRevision(ctx, store, c.String("revision"))
if err != nil {
return err
}
if rev == nil {
return fmt.Errorf("no indexed revision found")
}
opt, err := store.GetOption(ctx, rev.ID, name)
if err != nil {
return fmt.Errorf("failed to get option: %w", err)
}
if opt == nil {
return fmt.Errorf("option '%s' not found", name)
}
fmt.Printf("%s\n", opt.Name)
fmt.Printf(" Type: %s\n", opt.Type)
if opt.Description != "" {
fmt.Printf(" Description: %s\n", opt.Description)
}
if opt.DefaultValue != "" && opt.DefaultValue != "null" {
fmt.Printf(" Default: %s\n", opt.DefaultValue)
}
if opt.Example != "" && opt.Example != "null" {
fmt.Printf(" Example: %s\n", opt.Example)
}
if opt.ReadOnly {
fmt.Println(" Read-only: yes")
}
// Get declarations
declarations, err := store.GetDeclarations(ctx, opt.ID)
if err == nil && len(declarations) > 0 {
fmt.Println(" Declared in:")
for _, decl := range declarations {
if decl.Line > 0 {
fmt.Printf(" - %s:%d\n", decl.FilePath, decl.Line)
} else {
fmt.Printf(" - %s\n", decl.FilePath)
}
}
}
// Get children
children, err := store.GetChildren(ctx, rev.ID, opt.Name)
if err == nil && len(children) > 0 {
fmt.Println(" Sub-options:")
for _, child := range children {
shortName := child.Name
if strings.HasPrefix(child.Name, opt.Name+".") {
shortName = child.Name[len(opt.Name)+1:]
}
fmt.Printf(" - %s (%s)\n", shortName, child.Type)
}
}
return nil
}
// Packages search and get functions
func runPackagesSearch(c *cli.Context, query string) error {
ctx := context.Background()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
rev, err := resolveRevision(ctx, store, c.String("revision"))
if err != nil {
return err
}
if rev == nil {
return fmt.Errorf("no indexed revision found")
}
filters := database.PackageSearchFilters{
Limit: c.Int("limit"),
}
if c.IsSet("broken") {
broken := c.Bool("broken")
filters.Broken = &broken
}
if c.IsSet("unfree") {
unfree := c.Bool("unfree")
filters.Unfree = &unfree
}
pkgs, err := store.SearchPackages(ctx, rev.ID, query, filters)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(pkgs) == 0 {
fmt.Printf("No packages found matching '%s'\n", query)
return nil
}
fmt.Printf("Found %d packages matching '%s':\n\n", len(pkgs), query)
for _, pkg := range pkgs {
fmt.Printf(" %s\n", pkg.AttrPath)
fmt.Printf(" Name: %s", pkg.Pname)
if pkg.Version != "" {
fmt.Printf(" %s", pkg.Version)
}
fmt.Println()
if pkg.Description != "" {
desc := pkg.Description
if len(desc) > 100 {
desc = desc[:100] + "..."
}
fmt.Printf(" %s\n", desc)
}
if pkg.Broken || pkg.Unfree || pkg.Insecure {
var flags []string
if pkg.Broken {
flags = append(flags, "broken")
}
if pkg.Unfree {
flags = append(flags, "unfree")
}
if pkg.Insecure {
flags = append(flags, "insecure")
}
fmt.Printf(" Flags: %s\n", strings.Join(flags, ", "))
}
fmt.Println()
}
return nil
}
func runPackagesGet(c *cli.Context, attrPath string) error {
ctx := context.Background()
store, err := openStore(c.String("database"))
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer store.Close() //nolint:errcheck // cleanup on exit
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
rev, err := resolveRevision(ctx, store, c.String("revision"))
if err != nil {
return err
}
if rev == nil {
return fmt.Errorf("no indexed revision found")
}
pkg, err := store.GetPackage(ctx, rev.ID, attrPath)
if err != nil {
return fmt.Errorf("failed to get package: %w", err)
}
if pkg == nil {
return fmt.Errorf("package '%s' not found", attrPath)
}
fmt.Printf("%s\n", pkg.AttrPath)
fmt.Printf(" Name: %s\n", pkg.Pname)
if pkg.Version != "" {
fmt.Printf(" Version: %s\n", pkg.Version)
}
if pkg.Description != "" {
fmt.Printf(" Description: %s\n", pkg.Description)
}
if pkg.Homepage != "" {
fmt.Printf(" Homepage: %s\n", pkg.Homepage)
}
if pkg.License != "" && pkg.License != "[]" {
fmt.Printf(" License: %s\n", pkg.License)
}
if pkg.Maintainers != "" && pkg.Maintainers != "[]" {
fmt.Printf(" Maintainers: %s\n", pkg.Maintainers)
}
// Status flags
if pkg.Broken || pkg.Unfree || pkg.Insecure {
fmt.Println(" Status:")
if pkg.Broken {
fmt.Println(" - broken")
}
if pkg.Unfree {
fmt.Println(" - unfree")
}
if pkg.Insecure {
fmt.Println(" - insecure")
}
}
return nil
}
// resolveRevision finds a revision by hash or channel, or returns the most recent.
func resolveRevision(ctx context.Context, store database.Store, revisionArg string) (*database.Revision, error) {
if revisionArg != "" {
rev, err := store.GetRevision(ctx, revisionArg)
if err != nil {
return nil, fmt.Errorf("failed to get revision: %w", err)
}
if rev != nil {
return rev, nil
}
rev, err = store.GetRevisionByChannel(ctx, revisionArg)
if err != nil {
return nil, fmt.Errorf("failed to get revision: %w", err)
}
return rev, nil
}
// Return most recent
revisions, err := store.ListRevisions(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list revisions: %w", err)
}
if len(revisions) > 0 {
return revisions[0], nil
}
return nil, nil
}

View File

@@ -26,7 +26,14 @@
mainProgram = "hm-options"; mainProgram = "hm-options";
description = "MCP server for Home Manager options search and query"; description = "MCP server for Home Manager options search and query";
}; };
default = self.packages.${system}.nixos-options; nixpkgs-search = pkgs.callPackage ./nix/package.nix {
src = ./.;
pname = "nixpkgs-search";
subPackage = "cmd/nixpkgs-search";
mainProgram = "nixpkgs-search";
description = "Search nixpkgs options and packages";
};
default = self.packages.${system}.nixpkgs-search;
}); });
devShells = forAllSystems (system: devShells = forAllSystems (system:
@@ -54,6 +61,10 @@
}); });
nixosModules = { nixosModules = {
nixpkgs-search-mcp = { pkgs, ... }: {
imports = [ ./nix/nixpkgs-search-module.nix ];
services.nixpkgs-search.package = lib.mkDefault self.packages.${pkgs.system}.nixpkgs-search;
};
nixos-options-mcp = { pkgs, ... }: { nixos-options-mcp = { pkgs, ... }: {
imports = [ ./nix/module.nix ]; imports = [ ./nix/module.nix ];
services.nixos-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.nixos-options; services.nixos-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.nixos-options;
@@ -62,7 +73,7 @@
imports = [ ./nix/hm-options-module.nix ]; imports = [ ./nix/hm-options-module.nix ];
services.hm-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.hm-options; services.hm-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.hm-options;
}; };
default = self.nixosModules.nixos-options-mcp; default = self.nixosModules.nixpkgs-search-mcp;
}; };
}; };
} }

View File

@@ -14,6 +14,7 @@ type Revision struct {
CommitDate time.Time CommitDate time.Time
IndexedAt time.Time IndexedAt time.Time
OptionCount int OptionCount int
PackageCount int
} }
// Option represents a NixOS configuration option. // Option represents a NixOS configuration option.
@@ -48,6 +49,24 @@ type File struct {
LineCount int LineCount int
} }
// Package represents a Nix package from nixpkgs.
type Package struct {
ID int64
RevisionID int64
AttrPath string // e.g., "python312Packages.requests"
Pname string // Package name
Version string
Description string
LongDescription string
Homepage string
License string // JSON array
Platforms string // JSON array
Maintainers string // JSON array
Broken bool
Unfree bool
Insecure bool
}
// DeclarationWithMetadata includes declaration info plus file metadata. // DeclarationWithMetadata includes declaration info plus file metadata.
type DeclarationWithMetadata struct { type DeclarationWithMetadata struct {
Declaration Declaration
@@ -79,6 +98,15 @@ type SearchFilters struct {
Offset int Offset int
} }
// PackageSearchFilters contains optional filters for package search.
type PackageSearchFilters struct {
Broken *bool
Unfree *bool
Insecure *bool
Limit int
Offset int
}
// Store defines the interface for database operations. // Store defines the interface for database operations.
type Store interface { type Store interface {
// Schema operations // Schema operations
@@ -111,4 +139,11 @@ type Store interface {
CreateFilesBatch(ctx context.Context, files []*File) error CreateFilesBatch(ctx context.Context, files []*File) error
GetFile(ctx context.Context, revisionID int64, path string) (*File, error) GetFile(ctx context.Context, revisionID int64, path string) (*File, error)
GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error)
// Package operations
CreatePackage(ctx context.Context, pkg *Package) error
CreatePackagesBatch(ctx context.Context, pkgs []*Package) error
GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error)
SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error)
UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error
} }

View File

@@ -43,6 +43,7 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
dropStmts := []string{ dropStmts := []string{
DropDeclarations, DropDeclarations,
DropOptions, DropOptions,
DropPackages,
DropFiles, DropFiles,
DropRevisions, DropRevisions,
DropSchemaInfo, DropSchemaInfo,
@@ -64,7 +65,8 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
channel_name TEXT, channel_name TEXT,
commit_date TIMESTAMP, commit_date TIMESTAMP,
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
option_count INTEGER NOT NULL DEFAULT 0 option_count INTEGER NOT NULL DEFAULT 0,
package_count INTEGER NOT NULL DEFAULT 0
)`, )`,
`CREATE TABLE IF NOT EXISTS options ( `CREATE TABLE IF NOT EXISTS options (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -92,10 +94,28 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
byte_size INTEGER NOT NULL DEFAULT 0, byte_size INTEGER NOT NULL DEFAULT 0,
line_count INTEGER NOT NULL DEFAULT 0 line_count INTEGER NOT NULL DEFAULT 0
)`, )`,
`CREATE TABLE IF NOT EXISTS packages (
id SERIAL PRIMARY KEY,
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
attr_path TEXT NOT NULL,
pname TEXT NOT NULL,
version TEXT,
description TEXT,
long_description TEXT,
homepage TEXT,
license TEXT,
platforms TEXT,
maintainers TEXT,
broken BOOLEAN NOT NULL DEFAULT FALSE,
unfree BOOLEAN NOT NULL DEFAULT FALSE,
insecure BOOLEAN NOT NULL DEFAULT FALSE
)`,
IndexOptionsRevisionName, IndexOptionsRevisionName,
IndexOptionsRevisionParent, IndexOptionsRevisionParent,
IndexFilesRevisionPath, IndexFilesRevisionPath,
IndexDeclarationsOption, IndexDeclarationsOption,
IndexPackagesRevisionAttr,
IndexPackagesRevisionPname,
} }
for _, stmt := range createStmts { for _, stmt := range createStmts {
@@ -104,13 +124,22 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
} }
} }
// Create full-text search index for PostgreSQL // Create full-text search index for PostgreSQL options
_, err = s.db.ExecContext(ctx, ` _, err = s.db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_options_fts CREATE INDEX IF NOT EXISTS idx_options_fts
ON options USING GIN(to_tsvector('english', name || ' ' || COALESCE(description, ''))) ON options USING GIN(to_tsvector('english', name || ' ' || COALESCE(description, '')))
`) `)
if err != nil { if err != nil {
return fmt.Errorf("failed to create FTS index: %w", err) return fmt.Errorf("failed to create options FTS index: %w", err)
}
// Create full-text search index for PostgreSQL packages
_, err = s.db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_packages_fts
ON packages USING GIN(to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')))
`)
if err != nil {
return fmt.Errorf("failed to create packages FTS index: %w", err)
} }
// Set schema version // Set schema version
@@ -133,10 +162,10 @@ func (s *PostgresStore) Close() error {
// CreateRevision creates a new revision record. // CreateRevision creates a new revision record.
func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error { func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error {
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count) INSERT INTO revisions (git_hash, channel_name, commit_date, option_count, package_count)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING id, indexed_at`, RETURNING id, indexed_at`,
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.PackageCount,
).Scan(&rev.ID, &rev.IndexedAt) ).Scan(&rev.ID, &rev.IndexedAt)
if err != nil { if err != nil {
return fmt.Errorf("failed to create revision: %w", err) return fmt.Errorf("failed to create revision: %w", err)
@@ -148,9 +177,9 @@ func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error
func (s *PostgresStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) { func (s *PostgresStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) {
rev := &Revision{} rev := &Revision{}
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions WHERE git_hash = $1`, gitHash, FROM revisions WHERE git_hash = $1`, gitHash,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -164,10 +193,10 @@ func (s *PostgresStore) GetRevision(ctx context.Context, gitHash string) (*Revis
func (s *PostgresStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) { func (s *PostgresStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) {
rev := &Revision{} rev := &Revision{}
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions WHERE channel_name = $1 FROM revisions WHERE channel_name = $1
ORDER BY indexed_at DESC LIMIT 1`, channel, ORDER BY indexed_at DESC LIMIT 1`, channel,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -180,7 +209,7 @@ func (s *PostgresStore) GetRevisionByChannel(ctx context.Context, channel string
// ListRevisions returns all indexed revisions. // ListRevisions returns all indexed revisions.
func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error) { func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions ORDER BY indexed_at DESC`) FROM revisions ORDER BY indexed_at DESC`)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list revisions: %w", err) return nil, fmt.Errorf("failed to list revisions: %w", err)
@@ -190,7 +219,7 @@ func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error)
var revisions []*Revision var revisions []*Revision
for rows.Next() { for rows.Next() {
rev := &Revision{} rev := &Revision{}
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil { if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount); err != nil {
return nil, fmt.Errorf("failed to scan revision: %w", err) return nil, fmt.Errorf("failed to scan revision: %w", err)
} }
revisions = append(revisions, rev) revisions = append(revisions, rev)
@@ -542,3 +571,146 @@ func (s *PostgresStore) GetFileWithRange(ctx context.Context, revisionID int64,
return applyLineRange(file, r), nil return applyLineRange(file, r), nil
} }
// CreatePackage creates a new package record.
func (s *PostgresStore) CreatePackage(ctx context.Context, pkg *Package) error {
err := s.db.QueryRowContext(ctx, `
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id`,
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
).Scan(&pkg.ID)
if err != nil {
return fmt.Errorf("failed to create package: %w", err)
}
return nil
}
// CreatePackagesBatch creates multiple packages in a batch.
func (s *PostgresStore) CreatePackagesBatch(ctx context.Context, pkgs []*Package) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close() //nolint:errcheck // statement closed with transaction
for _, pkg := range pkgs {
err := stmt.QueryRowContext(ctx,
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
).Scan(&pkg.ID)
if err != nil {
return fmt.Errorf("failed to insert package %s: %w", pkg.AttrPath, err)
}
}
return tx.Commit()
}
// GetPackage retrieves a package by revision and attr_path.
func (s *PostgresStore) GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error) {
pkg := &Package{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure
FROM packages WHERE revision_id = $1 AND attr_path = $2`, revisionID, attrPath,
).Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get package: %w", err)
}
return pkg, nil
}
// SearchPackages searches for packages matching a query.
func (s *PostgresStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) {
// Query includes exact match priority:
// - Priority 0: exact pname match
// - Priority 1: exact attr_path match
// - Priority 2: pname starts with query
// - Priority 3: attr_path starts with query
// - Priority 4: FTS match (ordered by ts_rank)
baseQuery := `
SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure
FROM packages
WHERE revision_id = $1
AND to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)`
args := []interface{}{revisionID, query}
argNum := 3
if filters.Broken != nil {
baseQuery += fmt.Sprintf(" AND broken = $%d", argNum)
args = append(args, *filters.Broken)
argNum++
}
if filters.Unfree != nil {
baseQuery += fmt.Sprintf(" AND unfree = $%d", argNum)
args = append(args, *filters.Unfree)
argNum++
}
if filters.Insecure != nil {
baseQuery += fmt.Sprintf(" AND insecure = $%d", argNum)
args = append(args, *filters.Insecure)
argNum++
}
// Order by exact match priority, then ts_rank, then attr_path
// CASE returns priority (lower = better), ts_rank returns positive scores (higher = better, so DESC)
baseQuery += fmt.Sprintf(` ORDER BY
CASE
WHEN pname = $%d THEN 0
WHEN attr_path = $%d THEN 1
WHEN pname LIKE $%d THEN 2
WHEN attr_path LIKE $%d THEN 3
ELSE 4
END,
ts_rank(to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')), plainto_tsquery('english', $2)) DESC,
attr_path`, argNum, argNum+1, argNum+2, argNum+3)
// For LIKE comparisons, escape % and _ characters for PostgreSQL
likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_") + "%"
args = append(args, query, query, likeQuery, likeQuery)
if filters.Limit > 0 {
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)
}
if filters.Offset > 0 {
baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset)
}
rows, err := s.db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to search packages: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var packages []*Package
for rows.Next() {
pkg := &Package{}
if err := rows.Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure); err != nil {
return nil, fmt.Errorf("failed to scan package: %w", err)
}
packages = append(packages, pkg)
}
return packages, rows.Err()
}
// UpdateRevisionPackageCount updates the package count for a revision.
func (s *PostgresStore) UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error {
_, err := s.db.ExecContext(ctx,
"UPDATE revisions SET package_count = $1 WHERE id = $2", count, id)
if err != nil {
return fmt.Errorf("failed to update package count: %w", err)
}
return nil
}

View File

@@ -2,7 +2,7 @@ package database
// SchemaVersion is the current database schema version. // SchemaVersion is the current database schema version.
// When this changes, the database will be dropped and recreated. // When this changes, the database will be dropped and recreated.
const SchemaVersion = 2 const SchemaVersion = 3
// Common SQL statements shared between implementations. // Common SQL statements shared between implementations.
const ( const (
@@ -20,7 +20,8 @@ const (
channel_name TEXT, channel_name TEXT,
commit_date TIMESTAMP, commit_date TIMESTAMP,
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
option_count INTEGER NOT NULL DEFAULT 0 option_count INTEGER NOT NULL DEFAULT 0,
package_count INTEGER NOT NULL DEFAULT 0
)` )`
// OptionsTable creates the options table. // OptionsTable creates the options table.
@@ -57,6 +58,25 @@ const (
byte_size INTEGER NOT NULL DEFAULT 0, byte_size INTEGER NOT NULL DEFAULT 0,
line_count INTEGER NOT NULL DEFAULT 0 line_count INTEGER NOT NULL DEFAULT 0
)` )`
// PackagesTable creates the packages table.
PackagesTable = `
CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY,
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
attr_path TEXT NOT NULL,
pname TEXT NOT NULL,
version TEXT,
description TEXT,
long_description TEXT,
homepage TEXT,
license TEXT,
platforms TEXT,
maintainers TEXT,
broken BOOLEAN NOT NULL DEFAULT FALSE,
unfree BOOLEAN NOT NULL DEFAULT FALSE,
insecure BOOLEAN NOT NULL DEFAULT FALSE
)`
) )
// Index creation statements. // Index creation statements.
@@ -80,6 +100,16 @@ const (
IndexDeclarationsOption = ` IndexDeclarationsOption = `
CREATE INDEX IF NOT EXISTS idx_declarations_option CREATE INDEX IF NOT EXISTS idx_declarations_option
ON declarations(option_id)` ON declarations(option_id)`
// IndexPackagesRevisionAttr creates an index on packages(revision_id, attr_path).
IndexPackagesRevisionAttr = `
CREATE UNIQUE INDEX IF NOT EXISTS idx_packages_revision_attr
ON packages(revision_id, attr_path)`
// IndexPackagesRevisionPname creates an index on packages(revision_id, pname).
IndexPackagesRevisionPname = `
CREATE INDEX IF NOT EXISTS idx_packages_revision_pname
ON packages(revision_id, pname)`
) )
// Drop statements for schema recreation. // Drop statements for schema recreation.
@@ -87,6 +117,7 @@ const (
DropSchemaInfo = `DROP TABLE IF EXISTS schema_info` DropSchemaInfo = `DROP TABLE IF EXISTS schema_info`
DropDeclarations = `DROP TABLE IF EXISTS declarations` DropDeclarations = `DROP TABLE IF EXISTS declarations`
DropOptions = `DROP TABLE IF EXISTS options` DropOptions = `DROP TABLE IF EXISTS options`
DropPackages = `DROP TABLE IF EXISTS packages`
DropFiles = `DROP TABLE IF EXISTS files` DropFiles = `DROP TABLE IF EXISTS files`
DropRevisions = `DROP TABLE IF EXISTS revisions` DropRevisions = `DROP TABLE IF EXISTS revisions`
) )

View File

@@ -44,10 +44,12 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
dropStmts := []string{ dropStmts := []string{
DropDeclarations, DropDeclarations,
DropOptions, DropOptions,
DropPackages,
DropFiles, DropFiles,
DropRevisions, DropRevisions,
DropSchemaInfo, DropSchemaInfo,
"DROP TABLE IF EXISTS options_fts", "DROP TABLE IF EXISTS options_fts",
"DROP TABLE IF EXISTS packages_fts",
} }
for _, stmt := range dropStmts { for _, stmt := range dropStmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil { if _, err := s.db.ExecContext(ctx, stmt); err != nil {
@@ -63,10 +65,13 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
OptionsTable, OptionsTable,
DeclarationsTable, DeclarationsTable,
FilesTable, FilesTable,
PackagesTable,
IndexOptionsRevisionName, IndexOptionsRevisionName,
IndexOptionsRevisionParent, IndexOptionsRevisionParent,
IndexFilesRevisionPath, IndexFilesRevisionPath,
IndexDeclarationsOption, IndexDeclarationsOption,
IndexPackagesRevisionAttr,
IndexPackagesRevisionPname,
} }
for _, stmt := range createStmts { for _, stmt := range createStmts {
@@ -88,8 +93,8 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
return fmt.Errorf("failed to create FTS table: %w", err) return fmt.Errorf("failed to create FTS table: %w", err)
} }
// Create triggers to keep FTS in sync // Create triggers to keep options FTS in sync
triggers := []string{ optionsTriggers := []string{
`CREATE TRIGGER IF NOT EXISTS options_ai AFTER INSERT ON options BEGIN `CREATE TRIGGER IF NOT EXISTS options_ai AFTER INSERT ON options BEGIN
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description); INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
END`, END`,
@@ -101,9 +106,42 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description); INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
END`, END`,
} }
for _, trigger := range triggers { for _, trigger := range optionsTriggers {
if _, err := s.db.ExecContext(ctx, trigger); err != nil { if _, err := s.db.ExecContext(ctx, trigger); err != nil {
return fmt.Errorf("failed to create trigger: %w", err) return fmt.Errorf("failed to create options trigger: %w", err)
}
}
// Create FTS5 virtual table for packages full-text search
_, err = s.db.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS packages_fts USING fts5(
attr_path,
pname,
description,
content='packages',
content_rowid='id'
)
`)
if err != nil {
return fmt.Errorf("failed to create packages FTS table: %w", err)
}
// Create triggers to keep packages FTS in sync
packagesTriggers := []string{
`CREATE TRIGGER IF NOT EXISTS packages_ai AFTER INSERT ON packages BEGIN
INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description);
END`,
`CREATE TRIGGER IF NOT EXISTS packages_ad AFTER DELETE ON packages BEGIN
INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description);
END`,
`CREATE TRIGGER IF NOT EXISTS packages_au AFTER UPDATE ON packages BEGIN
INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description);
INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description);
END`,
}
for _, trigger := range packagesTriggers {
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
return fmt.Errorf("failed to create packages trigger: %w", err)
} }
} }
@@ -127,9 +165,9 @@ func (s *SQLiteStore) Close() error {
// CreateRevision creates a new revision record. // CreateRevision creates a new revision record.
func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error { func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
result, err := s.db.ExecContext(ctx, ` result, err := s.db.ExecContext(ctx, `
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count) INSERT INTO revisions (git_hash, channel_name, commit_date, option_count, package_count)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.PackageCount,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to create revision: %w", err) return fmt.Errorf("failed to create revision: %w", err)
@@ -155,9 +193,9 @@ func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) { func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) {
rev := &Revision{} rev := &Revision{}
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions WHERE git_hash = ?`, gitHash, FROM revisions WHERE git_hash = ?`, gitHash,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -171,10 +209,10 @@ func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revisio
func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) { func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) {
rev := &Revision{} rev := &Revision{}
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions WHERE channel_name = ? FROM revisions WHERE channel_name = ?
ORDER BY indexed_at DESC LIMIT 1`, channel, ORDER BY indexed_at DESC LIMIT 1`, channel,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -187,17 +225,17 @@ func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string)
// ListRevisions returns all indexed revisions. // ListRevisions returns all indexed revisions.
func (s *SQLiteStore) ListRevisions(ctx context.Context) ([]*Revision, error) { func (s *SQLiteStore) ListRevisions(ctx context.Context) ([]*Revision, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions ORDER BY indexed_at DESC`) FROM revisions ORDER BY indexed_at DESC`)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list revisions: %w", err) return nil, fmt.Errorf("failed to list revisions: %w", err)
} }
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration //nolint:errcheck // rows.Err() checked after iteration defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var revisions []*Revision var revisions []*Revision
for rows.Next() { for rows.Next() {
rev := &Revision{} rev := &Revision{}
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil { if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount); err != nil {
return nil, fmt.Errorf("failed to scan revision: %w", err) return nil, fmt.Errorf("failed to scan revision: %w", err)
} }
revisions = append(revisions, rev) revisions = append(revisions, rev)
@@ -588,6 +626,158 @@ func (s *SQLiteStore) GetFileWithRange(ctx context.Context, revisionID int64, pa
return applyLineRange(file, r), nil return applyLineRange(file, r), nil
} }
// CreatePackage creates a new package record.
func (s *SQLiteStore) CreatePackage(ctx context.Context, pkg *Package) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
)
if err != nil {
return fmt.Errorf("failed to create package: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
pkg.ID = id
return nil
}
// CreatePackagesBatch creates multiple packages in a batch.
func (s *SQLiteStore) CreatePackagesBatch(ctx context.Context, pkgs []*Package) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close() //nolint:errcheck // statement closed with transaction
for _, pkg := range pkgs {
result, err := stmt.ExecContext(ctx,
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
)
if err != nil {
return fmt.Errorf("failed to insert package %s: %w", pkg.AttrPath, err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
pkg.ID = id
}
return tx.Commit()
}
// GetPackage retrieves a package by revision and attr_path.
func (s *SQLiteStore) GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error) {
pkg := &Package{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure
FROM packages WHERE revision_id = ? AND attr_path = ?`, revisionID, attrPath,
).Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get package: %w", err)
}
return pkg, nil
}
// SearchPackages searches for packages matching a query.
func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) {
// Query includes exact match priority:
// - Priority 0: exact pname match
// - Priority 1: exact attr_path match
// - Priority 2: pname starts with query
// - Priority 3: attr_path starts with query
// - Priority 4: FTS match (ordered by bm25 rank)
baseQuery := `
SELECT p.id, p.revision_id, p.attr_path, p.pname, p.version, p.description, p.long_description, p.homepage, p.license, p.platforms, p.maintainers, p.broken, p.unfree, p.insecure
FROM packages p
INNER JOIN packages_fts fts ON p.id = fts.rowid
WHERE p.revision_id = ?
AND packages_fts MATCH ?`
// Escape the query for FTS5 by wrapping in double quotes for literal matching.
escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
// For LIKE comparisons, escape % and _ characters
likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_")
args := []interface{}{revisionID, escapedQuery}
if filters.Broken != nil {
baseQuery += " AND p.broken = ?"
args = append(args, *filters.Broken)
}
if filters.Unfree != nil {
baseQuery += " AND p.unfree = ?"
args = append(args, *filters.Unfree)
}
if filters.Insecure != nil {
baseQuery += " AND p.insecure = ?"
args = append(args, *filters.Insecure)
}
// Order by exact match priority, then FTS5 rank, then attr_path
// CASE returns priority (lower = better), bm25 returns negative scores (lower = better)
baseQuery += ` ORDER BY
CASE
WHEN p.pname = ? THEN 0
WHEN p.attr_path = ? THEN 1
WHEN p.pname LIKE ? ESCAPE '\' THEN 2
WHEN p.attr_path LIKE ? ESCAPE '\' THEN 3
ELSE 4
END,
bm25(packages_fts),
p.attr_path`
args = append(args, query, query, likeQuery+"%", likeQuery+"%")
if filters.Limit > 0 {
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)
}
if filters.Offset > 0 {
baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset)
}
rows, err := s.db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to search packages: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var packages []*Package
for rows.Next() {
pkg := &Package{}
if err := rows.Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure); err != nil {
return nil, fmt.Errorf("failed to scan package: %w", err)
}
packages = append(packages, pkg)
}
return packages, rows.Err()
}
// UpdateRevisionPackageCount updates the package count for a revision.
func (s *SQLiteStore) UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error {
_, err := s.db.ExecContext(ctx,
"UPDATE revisions SET package_count = ? WHERE id = ?", count, id)
if err != nil {
return fmt.Errorf("failed to update package count: %w", err)
}
return nil
}
// countLines counts the number of lines in content. // countLines counts the number of lines in content.
func countLines(content string) int { func countLines(content string) int {
if content == "" { if content == "" {

View File

@@ -10,9 +10,10 @@ import (
"git.t-juice.club/torjus/labmcp/internal/database" "git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/options" "git.t-juice.club/torjus/labmcp/internal/options"
"git.t-juice.club/torjus/labmcp/internal/packages"
) )
// RegisterHandlers registers all tool handlers on the server. // RegisterHandlers registers all tool handlers on the server for options mode.
func (s *Server) RegisterHandlers(indexer options.Indexer) { func (s *Server) RegisterHandlers(indexer options.Indexer) {
s.tools["search_options"] = s.handleSearchOptions s.tools["search_options"] = s.handleSearchOptions
s.tools["get_option"] = s.handleGetOption s.tools["get_option"] = s.handleGetOption
@@ -22,6 +23,15 @@ func (s *Server) RegisterHandlers(indexer options.Indexer) {
s.tools["delete_revision"] = s.handleDeleteRevision s.tools["delete_revision"] = s.handleDeleteRevision
} }
// RegisterPackageHandlers registers all tool handlers on the server for packages mode.
func (s *Server) RegisterPackageHandlers(pkgIndexer *packages.Indexer) {
s.tools["search_packages"] = s.handleSearchPackages
s.tools["get_package"] = s.handleGetPackage
s.tools["get_file"] = s.handleGetFile
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
s.tools["delete_revision"] = s.handleDeleteRevision
}
// handleSearchOptions handles the search_options tool. // handleSearchOptions handles the search_options tool.
func (s *Server) handleSearchOptions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { func (s *Server) handleSearchOptions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
query, _ := args["query"].(string) query, _ := args["query"].(string)
@@ -420,3 +430,196 @@ func formatJSON(s string) string {
} }
return result return result
} }
// handleSearchPackages handles the search_packages tool.
func (s *Server) handleSearchPackages(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
query, _ := args["query"].(string)
if query == "" {
return ErrorContent(fmt.Errorf("query is required")), nil
}
revision, _ := args["revision"].(string)
rev, err := s.resolveRevision(ctx, revision)
if err != nil {
return ErrorContent(err), nil
}
if rev == nil {
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
}
filters := database.PackageSearchFilters{
Limit: 50,
}
if broken, ok := args["broken"].(bool); ok {
filters.Broken = &broken
}
if unfree, ok := args["unfree"].(bool); ok {
filters.Unfree = &unfree
}
if limit, ok := args["limit"].(float64); ok && limit > 0 {
filters.Limit = int(limit)
}
pkgs, err := s.store.SearchPackages(ctx, rev.ID, query, filters)
if err != nil {
return ErrorContent(fmt.Errorf("search failed: %w", err)), nil
}
// Format results
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d packages matching '%s' in revision %s:\n\n", len(pkgs), query, rev.GitHash[:8]))
for _, pkg := range pkgs {
sb.WriteString(fmt.Sprintf("## %s\n", pkg.AttrPath))
sb.WriteString(fmt.Sprintf("**Name:** %s", pkg.Pname))
if pkg.Version != "" {
sb.WriteString(fmt.Sprintf(" %s", pkg.Version))
}
sb.WriteString("\n")
if pkg.Description != "" {
desc := pkg.Description
if len(desc) > 200 {
desc = desc[:200] + "..."
}
sb.WriteString(fmt.Sprintf("**Description:** %s\n", desc))
}
if pkg.Broken || pkg.Unfree || pkg.Insecure {
var flags []string
if pkg.Broken {
flags = append(flags, "broken")
}
if pkg.Unfree {
flags = append(flags, "unfree")
}
if pkg.Insecure {
flags = append(flags, "insecure")
}
sb.WriteString(fmt.Sprintf("**Flags:** %s\n", strings.Join(flags, ", ")))
}
sb.WriteString("\n")
}
return CallToolResult{
Content: []Content{TextContent(sb.String())},
}, nil
}
// handleGetPackage handles the get_package tool.
func (s *Server) handleGetPackage(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
attrPath, _ := args["attr_path"].(string)
if attrPath == "" {
return ErrorContent(fmt.Errorf("attr_path is required")), nil
}
revision, _ := args["revision"].(string)
rev, err := s.resolveRevision(ctx, revision)
if err != nil {
return ErrorContent(err), nil
}
if rev == nil {
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
}
pkg, err := s.store.GetPackage(ctx, rev.ID, attrPath)
if err != nil {
return ErrorContent(fmt.Errorf("failed to get package: %w", err)), nil
}
if pkg == nil {
return ErrorContent(fmt.Errorf("package '%s' not found", attrPath)), nil
}
// Format result
var sb strings.Builder
sb.WriteString(fmt.Sprintf("# %s\n\n", pkg.AttrPath))
sb.WriteString(fmt.Sprintf("**Package name:** %s\n", pkg.Pname))
if pkg.Version != "" {
sb.WriteString(fmt.Sprintf("**Version:** %s\n", pkg.Version))
}
if pkg.Description != "" {
sb.WriteString(fmt.Sprintf("\n**Description:**\n%s\n", pkg.Description))
}
if pkg.LongDescription != "" {
sb.WriteString(fmt.Sprintf("\n**Long description:**\n%s\n", pkg.LongDescription))
}
if pkg.Homepage != "" {
sb.WriteString(fmt.Sprintf("\n**Homepage:** %s\n", pkg.Homepage))
}
if pkg.License != "" && pkg.License != "[]" {
sb.WriteString(fmt.Sprintf("\n**License:** %s\n", formatJSONArray(pkg.License)))
}
if pkg.Maintainers != "" && pkg.Maintainers != "[]" {
sb.WriteString(fmt.Sprintf("\n**Maintainers:** %s\n", formatJSONArray(pkg.Maintainers)))
}
if pkg.Platforms != "" && pkg.Platforms != "[]" {
sb.WriteString(fmt.Sprintf("\n**Platforms:** %s\n", formatJSONArray(pkg.Platforms)))
}
// Status flags
if pkg.Broken || pkg.Unfree || pkg.Insecure {
sb.WriteString("\n**Status:**\n")
if pkg.Broken {
sb.WriteString("- ⚠️ This package is marked as **broken**\n")
}
if pkg.Unfree {
sb.WriteString("- This package has an **unfree** license\n")
}
if pkg.Insecure {
sb.WriteString("- ⚠️ This package is marked as **insecure**\n")
}
}
return CallToolResult{
Content: []Content{TextContent(sb.String())},
}, nil
}
// handleListRevisionsWithPackages handles the list_revisions tool for packages mode.
func (s *Server) handleListRevisionsWithPackages(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revisions, err := s.store.ListRevisions(ctx)
if err != nil {
return ErrorContent(fmt.Errorf("failed to list revisions: %w", err)), nil
}
if len(revisions) == 0 {
return CallToolResult{
Content: []Content{TextContent("No revisions indexed. Use the nixpkgs-search CLI to index packages.")},
}, nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Indexed revisions (%d):\n\n", len(revisions)))
for _, rev := range revisions {
sb.WriteString(fmt.Sprintf("- **%s**", rev.GitHash[:12]))
if rev.ChannelName != "" {
sb.WriteString(fmt.Sprintf(" (%s)", rev.ChannelName))
}
sb.WriteString(fmt.Sprintf("\n Options: %d, Packages: %d, Indexed: %s\n",
rev.OptionCount, rev.PackageCount, rev.IndexedAt.Format("2006-01-02 15:04")))
}
return CallToolResult{
Content: []Content{TextContent(sb.String())},
}, nil
}
// formatJSONArray formats a JSON array string as a comma-separated list.
func formatJSONArray(s string) string {
if s == "" || s == "[]" {
return ""
}
var arr []string
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return s
}
return strings.Join(arr, ", ")
}

View File

@@ -10,6 +10,16 @@ import (
"git.t-juice.club/torjus/labmcp/internal/database" "git.t-juice.club/torjus/labmcp/internal/database"
) )
// ServerMode indicates which type of tools the server should expose.
type ServerMode string
const (
// ModeOptions exposes only option-related tools.
ModeOptions ServerMode = "options"
// ModePackages exposes only package-related tools.
ModePackages ServerMode = "packages"
)
// ServerConfig contains configuration for the MCP server. // ServerConfig contains configuration for the MCP server.
type ServerConfig struct { type ServerConfig struct {
// Name is the server name reported in initialization. // Name is the server name reported in initialization.
@@ -22,15 +32,18 @@ type ServerConfig struct {
DefaultChannel string DefaultChannel string
// SourceName is the name of the source repository (e.g., "nixpkgs", "home-manager"). // SourceName is the name of the source repository (e.g., "nixpkgs", "home-manager").
SourceName string SourceName string
// Mode specifies which tools to expose (options or packages).
Mode ServerMode
} }
// DefaultNixOSConfig returns the default configuration for NixOS options server. // DefaultNixOSConfig returns the default configuration for NixOS options server.
func DefaultNixOSConfig() ServerConfig { func DefaultNixOSConfig() ServerConfig {
return ServerConfig{ return ServerConfig{
Name: "nixos-options", Name: "nixos-options",
Version: "0.1.2", Version: "0.2.0",
DefaultChannel: "nixos-stable", DefaultChannel: "nixos-stable",
SourceName: "nixpkgs", SourceName: "nixpkgs",
Mode: ModeOptions,
Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options. Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options.
If the current project contains a flake.lock file, you can index the exact nixpkgs revision used by the project: If the current project contains a flake.lock file, you can index the exact nixpkgs revision used by the project:
@@ -43,13 +56,32 @@ This ensures option documentation matches the nixpkgs version the project actual
} }
} }
// DefaultNixpkgsPackagesConfig returns the default configuration for nixpkgs packages server.
func DefaultNixpkgsPackagesConfig() ServerConfig {
return ServerConfig{
Name: "nixpkgs-packages",
Version: "0.2.0",
DefaultChannel: "nixos-stable",
SourceName: "nixpkgs",
Mode: ModePackages,
Instructions: `Nixpkgs Packages MCP Server - Search and query Nix packages from nixpkgs.
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
1. Read the flake.lock file to find the nixpkgs "rev" field
2. Ensure the revision is indexed (packages are indexed separately from options)
This ensures package information matches the nixpkgs version the project actually uses.`,
}
}
// DefaultHomeManagerConfig returns the default configuration for Home Manager options server. // DefaultHomeManagerConfig returns the default configuration for Home Manager options server.
func DefaultHomeManagerConfig() ServerConfig { func DefaultHomeManagerConfig() ServerConfig {
return ServerConfig{ return ServerConfig{
Name: "hm-options", Name: "hm-options",
Version: "0.1.2", Version: "0.2.0",
DefaultChannel: "hm-stable", DefaultChannel: "hm-stable",
SourceName: "home-manager", SourceName: "home-manager",
Mode: ModeOptions,
Instructions: `Home Manager Options MCP Server - Search and query Home Manager configuration options. Instructions: `Home Manager Options MCP Server - Search and query Home Manager configuration options.
If the current project contains a flake.lock file, you can index the exact home-manager revision used by the project: If the current project contains a flake.lock file, you can index the exact home-manager revision used by the project:
@@ -205,6 +237,17 @@ func (s *Server) handleToolsList(req *Request) *Response {
// getToolDefinitions returns the tool definitions. // getToolDefinitions returns the tool definitions.
func (s *Server) getToolDefinitions() []Tool { func (s *Server) getToolDefinitions() []Tool {
// For packages mode, return package tools
if s.config.Mode == ModePackages {
return s.getPackageToolDefinitions()
}
// Default: options mode
return s.getOptionToolDefinitions()
}
// getOptionToolDefinitions returns the tool definitions for options mode.
func (s *Server) getOptionToolDefinitions() []Tool {
// Determine naming based on source // Determine naming based on source
optionType := "NixOS" optionType := "NixOS"
sourceRepo := "nixpkgs" sourceRepo := "nixpkgs"
@@ -344,6 +387,114 @@ func (s *Server) getToolDefinitions() []Tool {
} }
} }
// getPackageToolDefinitions returns the tool definitions for packages mode.
func (s *Server) getPackageToolDefinitions() []Tool {
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
exampleFilePath := "pkgs/applications/networking/browsers/firefox/default.nix"
return []Tool{
{
Name: "search_packages",
Description: "Search for Nix packages by name or description",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"query": {
Type: "string",
Description: "Search query (matches package name, attr path, and description)",
},
"revision": {
Type: "string",
Description: fmt.Sprintf("Git hash or channel name (e.g., %s). Uses default if not specified.", exampleChannels),
},
"broken": {
Type: "boolean",
Description: "Filter by broken status (true = only broken, false = only working)",
},
"unfree": {
Type: "boolean",
Description: "Filter by license (true = only unfree, false = only free)",
},
"limit": {
Type: "integer",
Description: "Maximum number of results (default: 50)",
Default: 50,
},
},
Required: []string{"query"},
},
},
{
Name: "get_package",
Description: "Get full details for a specific Nix package by attribute path",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"attr_path": {
Type: "string",
Description: "Package attribute path (e.g., 'firefox', 'python312Packages.requests')",
},
"revision": {
Type: "string",
Description: "Git hash or channel name. Uses default if not specified.",
},
},
Required: []string{"attr_path"},
},
},
{
Name: "get_file",
Description: "Fetch the contents of a file from nixpkgs",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"path": {
Type: "string",
Description: fmt.Sprintf("File path relative to nixpkgs root (e.g., '%s')", exampleFilePath),
},
"revision": {
Type: "string",
Description: "Git hash or channel name. Uses default if not specified.",
},
"offset": {
Type: "integer",
Description: "Line offset (0-based). Default: 0",
Default: 0,
},
"limit": {
Type: "integer",
Description: "Maximum lines to return. Default: 250, use 0 for all lines",
Default: 250,
},
},
Required: []string{"path"},
},
},
{
Name: "list_revisions",
Description: "List all indexed nixpkgs revisions",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{},
},
},
{
Name: "delete_revision",
Description: "Delete an indexed revision and all its data",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"revision": {
Type: "string",
Description: "Git hash or channel name of the revision to delete",
},
},
Required: []string{"revision"},
},
},
}
}
// handleToolsCall handles a tool invocation. // handleToolsCall handles a tool invocation.
func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response { func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
var params CallToolParams var params CallToolParams

View File

@@ -0,0 +1,257 @@
package packages
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
)
// revisionPattern validates revision strings to prevent injection attacks.
// Allows: alphanumeric, hyphens, underscores, dots (for channel names like "nixos-24.11"
// and git hashes). Must be 1-64 characters.
var revisionPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
// Indexer handles indexing of packages from nixpkgs revisions.
type Indexer struct {
store database.Store
httpClient *http.Client
}
// NewIndexer creates a new packages indexer.
func NewIndexer(store database.Store) *Indexer {
return &Indexer{
store: store,
httpClient: &http.Client{
Timeout: 10 * time.Minute, // Longer timeout for package evaluation
},
}
}
// ValidateRevision checks if a revision string is safe to use.
// Returns an error if the revision contains potentially dangerous characters.
func ValidateRevision(revision string) error {
if !revisionPattern.MatchString(revision) {
return fmt.Errorf("invalid revision format: must be 1-64 alphanumeric characters, hyphens, underscores, or dots")
}
return nil
}
// IndexPackages indexes packages for an existing revision.
// The revision must already exist in the database (created by options indexer).
func (idx *Indexer) IndexPackages(ctx context.Context, revisionID int64, ref string) (*IndexResult, error) {
start := time.Now()
// Validate revision to prevent injection attacks
if err := ValidateRevision(ref); err != nil {
return nil, err
}
// Build packages JSON using nix-env
packagesPath, cleanup, err := idx.buildPackages(ctx, ref)
if err != nil {
return nil, fmt.Errorf("failed to build packages: %w", err)
}
defer cleanup()
// Parse and store packages using streaming to reduce memory usage
packagesFile, err := os.Open(packagesPath)
if err != nil {
return nil, fmt.Errorf("failed to open packages.json: %w", err)
}
defer packagesFile.Close() //nolint:errcheck // read-only file
// Store packages in batches
batch := make([]*database.Package, 0, 1000)
count := 0
_, err = ParsePackagesStream(packagesFile, func(pkg *ParsedPackage) error {
dbPkg := &database.Package{
RevisionID: revisionID,
AttrPath: pkg.AttrPath,
Pname: pkg.Pname,
Version: pkg.Version,
Description: pkg.Description,
LongDescription: pkg.LongDescription,
Homepage: pkg.Homepage,
License: pkg.License,
Platforms: pkg.Platforms,
Maintainers: pkg.Maintainers,
Broken: pkg.Broken,
Unfree: pkg.Unfree,
Insecure: pkg.Insecure,
}
batch = append(batch, dbPkg)
count++
// Store in batches
if len(batch) >= 1000 {
if err := idx.store.CreatePackagesBatch(ctx, batch); err != nil {
return fmt.Errorf("failed to store packages batch: %w", err)
}
batch = batch[:0]
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse packages: %w", err)
}
// Store remaining packages
if len(batch) > 0 {
if err := idx.store.CreatePackagesBatch(ctx, batch); err != nil {
return nil, fmt.Errorf("failed to store final packages batch: %w", err)
}
}
// Update revision package count
if err := idx.store.UpdateRevisionPackageCount(ctx, revisionID, count); err != nil {
return nil, fmt.Errorf("failed to update package count: %w", err)
}
return &IndexResult{
RevisionID: revisionID,
PackageCount: count,
Duration: time.Since(start),
}, nil
}
// buildPackages builds a JSON file containing all packages for a nixpkgs revision.
func (idx *Indexer) buildPackages(ctx context.Context, ref string) (string, func(), error) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "nixpkgs-packages-*")
if err != nil {
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
}
cleanup := func() {
_ = os.RemoveAll(tmpDir) //nolint:errcheck // best-effort temp dir cleanup
}
outputPath := filepath.Join(tmpDir, "packages.json")
// First, fetch the nixpkgs tarball to the nix store
// This ensures it's available for nix-env evaluation
nixExpr := fmt.Sprintf(`
builtins.fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/%s.tar.gz";
}
`, ref)
fetchCmd := exec.CommandContext(ctx, "nix-instantiate", "--eval", "-E", nixExpr)
fetchCmd.Dir = tmpDir
fetchOutput, err := fetchCmd.Output()
if err != nil {
cleanup()
if exitErr, ok := err.(*exec.ExitError); ok {
return "", nil, fmt.Errorf("nix-instantiate fetch failed: %s", string(exitErr.Stderr))
}
return "", nil, fmt.Errorf("nix-instantiate fetch failed: %w", err)
}
// The output is the store path in quotes, e.g., "/nix/store/xxx-source"
nixpkgsPath := strings.Trim(strings.TrimSpace(string(fetchOutput)), "\"")
// Run nix-env to get all packages as JSON
// Use --json --meta to get full metadata
cmd := exec.CommandContext(ctx, "nix-env",
"-f", nixpkgsPath,
"-qaP", "--json", "--meta",
)
cmd.Dir = tmpDir
// Create output file
outputFile, err := os.Create(outputPath)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to create output file: %w", err)
}
cmd.Stdout = outputFile
// Suppress stderr warnings about unfree/broken packages
cmd.Stderr = nil
err = cmd.Run()
outputFile.Close() //nolint:errcheck // output file, will check stat below
if err != nil {
cleanup()
if exitErr, ok := err.(*exec.ExitError); ok {
return "", nil, fmt.Errorf("nix-env failed: %s", string(exitErr.Stderr))
}
return "", nil, fmt.Errorf("nix-env failed: %w", err)
}
// Verify output file exists and has content
stat, err := os.Stat(outputPath)
if err != nil || stat.Size() == 0 {
cleanup()
return "", nil, fmt.Errorf("packages.json not found or empty")
}
return outputPath, cleanup, nil
}
// ResolveRevision resolves a channel name or ref to a git ref.
func (idx *Indexer) ResolveRevision(revision string) string {
if ref, ok := ChannelAliases[revision]; ok {
return ref
}
return revision
}
// GetChannelName returns the channel name if the revision matches one.
func (idx *Indexer) GetChannelName(revision string) string {
if _, ok := ChannelAliases[revision]; ok {
return revision
}
// Check if the revision is a channel ref value
for name, ref := range ChannelAliases {
if ref == revision {
return name
}
}
return ""
}
// GetCommitDate gets the commit date for a git ref using GitHub API.
func (idx *Indexer) GetCommitDate(ctx context.Context, ref string) (time.Time, error) {
url := fmt.Sprintf("https://api.github.com/repos/NixOS/nixpkgs/commits/%s", ref)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return time.Time{}, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := idx.httpClient.Do(req)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close() //nolint:errcheck // response body read-only
if resp.StatusCode != http.StatusOK {
return time.Time{}, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var commit struct {
Commit struct {
Committer struct {
Date time.Time `json:"date"`
} `json:"committer"`
} `json:"commit"`
}
if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil {
return time.Time{}, err
}
return commit.Commit.Committer.Date, nil
}

View File

@@ -0,0 +1,82 @@
package packages
import (
"testing"
)
func TestValidateRevision(t *testing.T) {
tests := []struct {
name string
revision string
expectErr bool
}{
{"valid hash", "abc123def456", false},
{"valid channel", "nixos-unstable", false},
{"valid version channel", "nixos-24.11", false},
{"empty", "", true},
{"too long", "a" + string(make([]byte, 100)), true},
{"shell injection", "$(rm -rf /)", true},
{"path traversal", "../../../etc/passwd", true},
{"semicolon", "abc;rm -rf /", true},
{"backtick", "`whoami`", true},
{"space", "abc def", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateRevision(tc.revision)
if tc.expectErr && err == nil {
t.Error("Expected error, got nil")
}
if !tc.expectErr && err != nil {
t.Errorf("Expected no error, got %v", err)
}
})
}
}
func TestResolveRevision(t *testing.T) {
idx := &Indexer{}
tests := []struct {
input string
expected string
}{
{"nixos-unstable", "nixos-unstable"},
{"nixos-stable", "nixos-24.11"},
{"nixos-24.11", "nixos-24.11"},
{"abc123", "abc123"},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result := idx.ResolveRevision(tc.input)
if result != tc.expected {
t.Errorf("Expected %q, got %q", tc.expected, result)
}
})
}
}
func TestGetChannelName(t *testing.T) {
idx := &Indexer{}
tests := []struct {
input string
expected string
}{
{"nixos-unstable", "nixos-unstable"},
{"nixos-stable", "nixos-stable"},
{"nixos-24.11", "nixos-24.11"},
{"abc123", ""},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result := idx.GetChannelName(tc.input)
if result != tc.expected {
t.Errorf("Expected %q, got %q", tc.expected, result)
}
})
}
}

199
internal/packages/parser.go Normal file
View File

@@ -0,0 +1,199 @@
package packages
import (
"encoding/json"
"fmt"
"io"
"strings"
)
// ParsePackages reads and parses a nix-env JSON output file.
func ParsePackages(r io.Reader) (map[string]*ParsedPackage, error) {
var raw PackagesFile
if err := json.NewDecoder(r).Decode(&raw); err != nil {
return nil, fmt.Errorf("failed to decode packages JSON: %w", err)
}
packages := make(map[string]*ParsedPackage, len(raw))
for attrPath, pkg := range raw {
parsed := &ParsedPackage{
AttrPath: attrPath,
Pname: pkg.Pname,
Version: pkg.Version,
Description: pkg.Meta.Description,
LongDescription: pkg.Meta.LongDescription,
Homepage: normalizeHomepage(pkg.Meta.Homepage),
License: normalizeLicense(pkg.Meta.License),
Platforms: normalizePlatforms(pkg.Meta.Platforms),
Maintainers: normalizeMaintainers(pkg.Meta.Maintainers),
Broken: pkg.Meta.Broken,
Unfree: pkg.Meta.Unfree,
Insecure: pkg.Meta.Insecure,
}
packages[attrPath] = parsed
}
return packages, nil
}
// normalizeHomepage converts homepage to a string.
func normalizeHomepage(v interface{}) string {
if v == nil {
return ""
}
switch hp := v.(type) {
case string:
return hp
case []interface{}:
if len(hp) > 0 {
if s, ok := hp[0].(string); ok {
return s
}
}
}
return ""
}
// normalizeLicense converts license to a JSON array string.
func normalizeLicense(v interface{}) string {
if v == nil {
return "[]"
}
licenses := make([]string, 0)
switch l := v.(type) {
case string:
licenses = append(licenses, l)
case map[string]interface{}:
// Single license object
if spdxID, ok := l["spdxId"].(string); ok {
licenses = append(licenses, spdxID)
} else if fullName, ok := l["fullName"].(string); ok {
licenses = append(licenses, fullName)
} else if shortName, ok := l["shortName"].(string); ok {
licenses = append(licenses, shortName)
}
case []interface{}:
for _, item := range l {
switch li := item.(type) {
case string:
licenses = append(licenses, li)
case map[string]interface{}:
if spdxID, ok := li["spdxId"].(string); ok {
licenses = append(licenses, spdxID)
} else if fullName, ok := li["fullName"].(string); ok {
licenses = append(licenses, fullName)
} else if shortName, ok := li["shortName"].(string); ok {
licenses = append(licenses, shortName)
}
}
}
}
data, _ := json.Marshal(licenses)
return string(data)
}
// normalizePlatforms converts platforms to a JSON array string.
func normalizePlatforms(v []interface{}) string {
if v == nil {
return "[]"
}
platforms := make([]string, 0, len(v))
for _, p := range v {
switch pv := p.(type) {
case string:
platforms = append(platforms, pv)
// Skip complex platform specs (objects)
}
}
data, _ := json.Marshal(platforms)
return string(data)
}
// normalizeMaintainers converts maintainers to a JSON array string.
func normalizeMaintainers(maintainers []Maintainer) string {
if len(maintainers) == 0 {
return "[]"
}
names := make([]string, 0, len(maintainers))
for _, m := range maintainers {
name := m.Name
if name == "" && m.Github != "" {
name = "@" + m.Github
}
if name != "" {
names = append(names, name)
}
}
data, _ := json.Marshal(names)
return string(data)
}
// ParsePackagesStream parses packages from a reader using streaming to reduce memory usage.
// It yields parsed packages through a callback function.
func ParsePackagesStream(r io.Reader, callback func(*ParsedPackage) error) (int, error) {
dec := json.NewDecoder(r)
// Read the opening brace
t, err := dec.Token()
if err != nil {
return 0, fmt.Errorf("failed to read opening token: %w", err)
}
if delim, ok := t.(json.Delim); !ok || delim != '{' {
return 0, fmt.Errorf("expected opening brace, got %v", t)
}
count := 0
for dec.More() {
// Read the key (attr path)
t, err := dec.Token()
if err != nil {
return count, fmt.Errorf("failed to read attr path: %w", err)
}
attrPath, ok := t.(string)
if !ok {
return count, fmt.Errorf("expected string key, got %T", t)
}
// Read the value (package)
var pkg RawPackage
if err := dec.Decode(&pkg); err != nil {
// Skip malformed packages
continue
}
parsed := &ParsedPackage{
AttrPath: attrPath,
Pname: pkg.Pname,
Version: pkg.Version,
Description: pkg.Meta.Description,
LongDescription: pkg.Meta.LongDescription,
Homepage: normalizeHomepage(pkg.Meta.Homepage),
License: normalizeLicense(pkg.Meta.License),
Platforms: normalizePlatforms(pkg.Meta.Platforms),
Maintainers: normalizeMaintainers(pkg.Meta.Maintainers),
Broken: pkg.Meta.Broken,
Unfree: pkg.Meta.Unfree,
Insecure: pkg.Meta.Insecure,
}
if err := callback(parsed); err != nil {
return count, fmt.Errorf("callback error for %s: %w", attrPath, err)
}
count++
}
return count, nil
}
// SplitAttrPath splits an attribute path into its components.
// For example, "python312Packages.requests" returns ["python312Packages", "requests"].
func SplitAttrPath(attrPath string) []string {
return strings.Split(attrPath, ".")
}

View File

@@ -0,0 +1,215 @@
package packages
import (
"strings"
"testing"
)
func TestParsePackages(t *testing.T) {
input := `{
"firefox": {
"name": "firefox-120.0",
"pname": "firefox",
"version": "120.0",
"system": "x86_64-linux",
"meta": {
"description": "A web browser built from Firefox source tree",
"homepage": "https://www.mozilla.org/firefox/",
"license": {"spdxId": "MPL-2.0", "fullName": "Mozilla Public License 2.0"},
"maintainers": [
{"name": "John Doe", "github": "johndoe", "githubId": 12345}
],
"platforms": ["x86_64-linux", "aarch64-linux"]
}
},
"python312Packages.requests": {
"name": "python3.12-requests-2.31.0",
"pname": "requests",
"version": "2.31.0",
"system": "x86_64-linux",
"meta": {
"description": "HTTP library for Python",
"homepage": ["https://requests.readthedocs.io/"],
"license": [{"spdxId": "Apache-2.0"}],
"unfree": false
}
}
}`
packages, err := ParsePackages(strings.NewReader(input))
if err != nil {
t.Fatalf("ParsePackages failed: %v", err)
}
if len(packages) != 2 {
t.Errorf("Expected 2 packages, got %d", len(packages))
}
// Check firefox
firefox, ok := packages["firefox"]
if !ok {
t.Fatal("firefox package not found")
}
if firefox.Pname != "firefox" {
t.Errorf("Expected pname 'firefox', got %q", firefox.Pname)
}
if firefox.Version != "120.0" {
t.Errorf("Expected version '120.0', got %q", firefox.Version)
}
if firefox.Homepage != "https://www.mozilla.org/firefox/" {
t.Errorf("Expected homepage 'https://www.mozilla.org/firefox/', got %q", firefox.Homepage)
}
if firefox.License != `["MPL-2.0"]` {
t.Errorf("Expected license '[\"MPL-2.0\"]', got %q", firefox.License)
}
// Check python requests
requests, ok := packages["python312Packages.requests"]
if !ok {
t.Fatal("python312Packages.requests package not found")
}
if requests.Pname != "requests" {
t.Errorf("Expected pname 'requests', got %q", requests.Pname)
}
// Homepage is array, should extract first element
if requests.Homepage != "https://requests.readthedocs.io/" {
t.Errorf("Expected homepage 'https://requests.readthedocs.io/', got %q", requests.Homepage)
}
}
func TestParsePackagesStream(t *testing.T) {
input := `{
"hello": {
"name": "hello-2.12",
"pname": "hello",
"version": "2.12",
"system": "x86_64-linux",
"meta": {
"description": "A program that produces a familiar, friendly greeting"
}
},
"world": {
"name": "world-1.0",
"pname": "world",
"version": "1.0",
"system": "x86_64-linux",
"meta": {}
}
}`
var packages []*ParsedPackage
count, err := ParsePackagesStream(strings.NewReader(input), func(pkg *ParsedPackage) error {
packages = append(packages, pkg)
return nil
})
if err != nil {
t.Fatalf("ParsePackagesStream failed: %v", err)
}
if count != 2 {
t.Errorf("Expected count 2, got %d", count)
}
if len(packages) != 2 {
t.Errorf("Expected 2 packages, got %d", len(packages))
}
}
func TestNormalizeLicense(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string
}{
{"nil", nil, "[]"},
{"string", "MIT", `["MIT"]`},
{"object with spdxId", map[string]interface{}{"spdxId": "MIT"}, `["MIT"]`},
{"object with fullName", map[string]interface{}{"fullName": "MIT License"}, `["MIT License"]`},
{"array of strings", []interface{}{"MIT", "Apache-2.0"}, `["MIT","Apache-2.0"]`},
{"array of objects", []interface{}{
map[string]interface{}{"spdxId": "MIT"},
map[string]interface{}{"spdxId": "Apache-2.0"},
}, `["MIT","Apache-2.0"]`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := normalizeLicense(tc.input)
if result != tc.expected {
t.Errorf("Expected %q, got %q", tc.expected, result)
}
})
}
}
func TestNormalizeHomepage(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string
}{
{"nil", nil, ""},
{"string", "https://example.com", "https://example.com"},
{"array", []interface{}{"https://example.com", "https://docs.example.com"}, "https://example.com"},
{"empty array", []interface{}{}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := normalizeHomepage(tc.input)
if result != tc.expected {
t.Errorf("Expected %q, got %q", tc.expected, result)
}
})
}
}
func TestNormalizeMaintainers(t *testing.T) {
tests := []struct {
name string
maintainers []Maintainer
expected string
}{
{"empty", nil, "[]"},
{"with name", []Maintainer{{Name: "John Doe"}}, `["John Doe"]`},
{"with github only", []Maintainer{{Github: "johndoe"}}, `["@johndoe"]`},
{"multiple", []Maintainer{{Name: "Alice"}, {Name: "Bob"}}, `["Alice","Bob"]`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := normalizeMaintainers(tc.maintainers)
if result != tc.expected {
t.Errorf("Expected %q, got %q", tc.expected, result)
}
})
}
}
func TestSplitAttrPath(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"firefox", []string{"firefox"}},
{"python312Packages.requests", []string{"python312Packages", "requests"}},
{"haskellPackages.aeson.components.library", []string{"haskellPackages", "aeson", "components", "library"}},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result := SplitAttrPath(tc.input)
if len(result) != len(tc.expected) {
t.Errorf("Expected %v, got %v", tc.expected, result)
return
}
for i := range result {
if result[i] != tc.expected[i] {
t.Errorf("Expected %v, got %v", tc.expected, result)
return
}
}
})
}
}

View File

@@ -0,0 +1,78 @@
// Package packages contains types and logic for indexing Nix packages.
package packages
// RawPackage represents a package as parsed from nix-env --json output.
type RawPackage struct {
Pname string `json:"pname"`
Version string `json:"version"`
System string `json:"system"`
Meta RawPackageMeta `json:"meta"`
Name string `json:"name"`
OutputName string `json:"outputName,omitempty"`
Outputs map[string]interface{} `json:"outputs,omitempty"`
}
// RawPackageMeta contains package metadata.
type RawPackageMeta struct {
Available bool `json:"available,omitempty"`
Broken bool `json:"broken,omitempty"`
Description string `json:"description,omitempty"`
Homepage interface{} `json:"homepage,omitempty"` // Can be string or []string
Insecure bool `json:"insecure,omitempty"`
License interface{} `json:"license,omitempty"` // Can be string, object, or []interface{}
LongDescription string `json:"longDescription,omitempty"`
Maintainers []Maintainer `json:"maintainers,omitempty"`
Name string `json:"name,omitempty"`
OutputsToInstall []string `json:"outputsToInstall,omitempty"`
Platforms []interface{} `json:"platforms,omitempty"` // Can be strings or objects
Position string `json:"position,omitempty"`
Unfree bool `json:"unfree,omitempty"`
}
// Maintainer represents a package maintainer.
type Maintainer struct {
Email string `json:"email,omitempty"`
Github string `json:"github,omitempty"`
GithubID int `json:"githubId,omitempty"`
Matrix string `json:"matrix,omitempty"`
Name string `json:"name,omitempty"`
}
// ParsedPackage represents a package ready for database storage.
type ParsedPackage struct {
AttrPath string
Pname string
Version string
Description string
LongDescription string
Homepage string
License string // JSON array
Platforms string // JSON array
Maintainers string // JSON array
Broken bool
Unfree bool
Insecure bool
}
// PackagesFile represents the top-level structure of nix-env JSON output.
// It's a map from attr path to package definition.
type PackagesFile map[string]RawPackage
// ChannelAliases maps friendly channel names to their git branch/ref patterns.
// These are the same as NixOS options since packages come from the same repo.
var ChannelAliases = map[string]string{
"nixos-unstable": "nixos-unstable",
"nixos-stable": "nixos-24.11",
"nixos-24.11": "nixos-24.11",
"nixos-24.05": "nixos-24.05",
"nixos-23.11": "nixos-23.11",
"nixos-23.05": "nixos-23.05",
}
// IndexResult contains the results of a package indexing operation.
type IndexResult struct {
RevisionID int64
PackageCount int
Duration interface{} // time.Duration - kept as interface to avoid import cycle
AlreadyIndexed bool // True if revision already has packages
}

View File

@@ -0,0 +1,383 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.nixpkgs-search;
# Determine database URL based on configuration
# For postgres with connectionStringFile, the URL is set at runtime via script
useConnectionStringFile = cfg.database.type == "postgres" && cfg.database.connectionStringFile != null;
databaseUrl = if cfg.database.type == "sqlite"
then "sqlite://${cfg.dataDir}/${cfg.database.name}"
else if useConnectionStringFile
then "" # Will be set at runtime from file
else cfg.database.connectionString;
# Build HTTP transport flags for a service
mkHttpFlags = httpCfg: lib.concatStringsSep " " ([
"--transport http"
"--http-address '${httpCfg.address}'"
"--http-endpoint '${httpCfg.endpoint}'"
"--session-ttl '${httpCfg.sessionTTL}'"
] ++ lib.optionals (httpCfg.allowedOrigins != []) (
map (origin: "--allowed-origins '${origin}'") httpCfg.allowedOrigins
) ++ lib.optionals httpCfg.tls.enable [
"--tls-cert '${httpCfg.tls.certFile}'"
"--tls-key '${httpCfg.tls.keyFile}'"
]);
# Common HTTP options
mkHttpOptions = defaultPort: {
address = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1:${toString defaultPort}";
description = "HTTP listen address for the MCP server.";
};
endpoint = lib.mkOption {
type = lib.types.str;
default = "/mcp";
description = "HTTP endpoint path for MCP requests.";
};
allowedOrigins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "http://localhost:3000" "https://example.com" ];
description = ''
Allowed Origin headers for CORS.
Empty list means only localhost origins are allowed.
'';
};
sessionTTL = lib.mkOption {
type = lib.types.str;
default = "30m";
description = "Session TTL for HTTP transport (Go duration format).";
};
tls = {
enable = lib.mkEnableOption "TLS for HTTP transport";
certFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to TLS certificate file.";
};
keyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to TLS private key file.";
};
};
};
# Service configuration factory
mkServiceConfig = serviceName: subcommand: httpCfg: {
description = "Nixpkgs Search ${serviceName} MCP Server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]
++ lib.optional (cfg.database.type == "postgres") "postgresql.service";
environment = lib.mkIf (!useConnectionStringFile) {
NIXPKGS_SEARCH_DATABASE = databaseUrl;
};
path = [ cfg.package ];
script = let
httpFlags = mkHttpFlags httpCfg;
in
if useConnectionStringFile then ''
# Read database connection string from file
if [ ! -f "${cfg.database.connectionStringFile}" ]; then
echo "Error: connectionStringFile not found: ${cfg.database.connectionStringFile}" >&2
exit 1
fi
export NIXPKGS_SEARCH_DATABASE="$(cat "${cfg.database.connectionStringFile}")"
exec nixpkgs-search ${subcommand} serve ${httpFlags}
'' else ''
exec nixpkgs-search ${subcommand} serve ${httpFlags}
'';
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
MemoryDenyWriteExecute = true;
LockPersonality = true;
ReadWritePaths = [ cfg.dataDir ];
WorkingDirectory = cfg.dataDir;
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/nixpkgs-search") "nixpkgs-search";
};
};
in
{
options.services.nixpkgs-search = {
enable = lib.mkEnableOption "Nixpkgs Search MCP server(s)";
package = lib.mkPackageOption pkgs "nixpkgs-search" { };
user = lib.mkOption {
type = lib.types.str;
default = "nixpkgs-search";
description = "User account under which the service runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "nixpkgs-search";
description = "Group under which the service runs.";
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/nixpkgs-search";
description = "Directory to store data files.";
};
database = {
type = lib.mkOption {
type = lib.types.enum [ "sqlite" "postgres" ];
default = "sqlite";
description = "Database backend to use.";
};
name = lib.mkOption {
type = lib.types.str;
default = "nixpkgs-search.db";
description = "SQLite database filename (when using sqlite backend).";
};
connectionString = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
PostgreSQL connection string (when using postgres backend).
Example: "postgres://user:password@localhost/nixpkgs_search?sslmode=disable"
WARNING: This value will be stored in the Nix store, which is world-readable.
For production use with sensitive credentials, use connectionStringFile instead.
'';
};
connectionStringFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to a file containing the PostgreSQL connection string.
The file should contain just the connection string, e.g.:
postgres://user:password@localhost/nixpkgs_search?sslmode=disable
This is the recommended way to configure PostgreSQL credentials
as the file is not stored in the world-readable Nix store.
The file must be readable by the service user.
'';
example = "/run/secrets/nixpkgs-search-db";
};
};
indexOnStart = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "nixos-unstable" "nixos-24.11" ];
description = ''
List of nixpkgs revisions to index on service start.
Can be channel names (nixos-unstable) or git hashes.
Indexing is skipped if the revision is already indexed.
'';
};
indexFlags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "--no-packages" "--no-files" ];
description = ''
Additional flags to pass to the index command.
Useful for skipping packages (--no-packages), options (--no-options),
or files (--no-files) during indexing.
'';
};
options = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable the NixOS options MCP server.";
};
http = mkHttpOptions 8082;
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the options MCP HTTP server.";
};
};
packages = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable the Nix packages MCP server.";
};
http = mkHttpOptions 8083;
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the packages MCP HTTP server.";
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.database.type == "sqlite"
|| cfg.database.connectionString != ""
|| cfg.database.connectionStringFile != null;
message = "services.nixpkgs-search.database: when using postgres backend, either connectionString or connectionStringFile must be set";
}
{
assertion = cfg.database.connectionString == "" || cfg.database.connectionStringFile == null;
message = "services.nixpkgs-search.database: connectionString and connectionStringFile are mutually exclusive";
}
{
assertion = !cfg.options.http.tls.enable || (cfg.options.http.tls.certFile != null && cfg.options.http.tls.keyFile != null);
message = "services.nixpkgs-search.options.http.tls: both certFile and keyFile must be set when TLS is enabled";
}
{
assertion = !cfg.packages.http.tls.enable || (cfg.packages.http.tls.certFile != null && cfg.packages.http.tls.keyFile != null);
message = "services.nixpkgs-search.packages.http.tls: both certFile and keyFile must be set when TLS is enabled";
}
{
assertion = cfg.options.enable || cfg.packages.enable;
message = "services.nixpkgs-search: at least one of options.enable or packages.enable must be true";
}
];
users.users.${cfg.user} = lib.mkIf (cfg.user == "nixpkgs-search") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
description = "Nixpkgs Search MCP server user";
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "nixpkgs-search") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
];
# Indexing service (runs once on startup if indexOnStart is set)
systemd.services.nixpkgs-search-index = lib.mkIf (cfg.indexOnStart != []) {
description = "Nixpkgs Search Indexer";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]
++ lib.optional (cfg.database.type == "postgres") "postgresql.service";
before = lib.optionals cfg.options.enable [ "nixpkgs-search-options.service" ]
++ lib.optionals cfg.packages.enable [ "nixpkgs-search-packages.service" ];
environment = lib.mkIf (!useConnectionStringFile) {
NIXPKGS_SEARCH_DATABASE = databaseUrl;
};
path = [ cfg.package ];
script = let
indexFlags = lib.concatStringsSep " " cfg.indexFlags;
indexCommands = lib.concatMapStringsSep "\n" (rev: ''
echo "Indexing revision: ${rev}"
nixpkgs-search index ${indexFlags} "${rev}" || true
'') cfg.indexOnStart;
in
if useConnectionStringFile then ''
# Read database connection string from file
if [ ! -f "${cfg.database.connectionStringFile}" ]; then
echo "Error: connectionStringFile not found: ${cfg.database.connectionStringFile}" >&2
exit 1
fi
export NIXPKGS_SEARCH_DATABASE="$(cat "${cfg.database.connectionStringFile}")"
${indexCommands}
'' else ''
${indexCommands}
'';
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
RemainAfterExit = true;
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
ReadWritePaths = [ cfg.dataDir ];
WorkingDirectory = cfg.dataDir;
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/nixpkgs-search") "nixpkgs-search";
};
};
# Options MCP server
systemd.services.nixpkgs-search-options = lib.mkIf cfg.options.enable
(mkServiceConfig "Options" "options" cfg.options.http // {
after = (mkServiceConfig "Options" "options" cfg.options.http).after
++ lib.optionals (cfg.indexOnStart != []) [ "nixpkgs-search-index.service" ];
});
# Packages MCP server
systemd.services.nixpkgs-search-packages = lib.mkIf cfg.packages.enable
(mkServiceConfig "Packages" "packages" cfg.packages.http // {
after = (mkServiceConfig "Packages" "packages" cfg.packages.http).after
++ lib.optionals (cfg.indexOnStart != []) [ "nixpkgs-search-index.service" ];
});
# Open firewall ports if configured
networking.firewall = lib.mkMerge [
(lib.mkIf cfg.options.openFirewall (let
addressParts = lib.splitString ":" cfg.options.http.address;
port = lib.toInt (lib.last addressParts);
in {
allowedTCPPorts = [ port ];
}))
(lib.mkIf cfg.packages.openFirewall (let
addressParts = lib.splitString ":" cfg.packages.http.address;
port = lib.toInt (lib.last addressParts);
in {
allowedTCPPorts = [ port ];
}))
];
};
}

View File

@@ -7,7 +7,7 @@
buildGoModule { buildGoModule {
inherit pname src; inherit pname src;
version = "0.1.2"; version = "0.2.0";
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY="; vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";