Merge pull request 'feature/hm-options' (#2) from feature/hm-options into master

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-02-03 22:40:08 +00:00
18 changed files with 1931 additions and 152 deletions

108
CLAUDE.md
View File

@@ -6,9 +6,20 @@ This file provides context for Claude when working on this project.
**LabMCP** is a collection of Model Context Protocol (MCP) servers written in Go, designed to extend Claude's capabilities with custom tools. The repository is structured to be generic and extensible, allowing multiple MCP servers to be added over time. **LabMCP** is a collection of Model Context Protocol (MCP) servers written in Go, designed to extend Claude's capabilities with custom tools. The repository is structured to be generic and extensible, allowing multiple MCP servers to be added over time.
## Current Focus: NixOS Options MCP Server ## MCP Servers
The first MCP server provides search and query capabilities for NixOS configuration options. This addresses the challenge of incomplete or hard-to-find documentation in the Nix ecosystem. ### NixOS Options (`nixos-options`)
Search and query NixOS configuration options. Uses nixpkgs as source.
### Home Manager Options (`hm-options`)
Search and query Home Manager configuration options. Uses home-manager repository as source.
Both servers share the same architecture:
- Full-text search across option names and descriptions
- Query specific options with type, default, example, and declarations
- Index multiple revisions (by git hash or channel name)
- Fetch module source files
- PostgreSQL and SQLite backends
## Technology Stack ## Technology Stack
@@ -21,9 +32,9 @@ The first MCP server provides search and query capabilities for NixOS configurat
## Project Status ## Project Status
**Complete and maintained** - All core features implemented: **Complete and maintained** - All core features implemented:
- Full MCP server with 6 tools - Full MCP servers with 6 tools each
- PostgreSQL and SQLite backends with FTS - PostgreSQL and SQLite backends with FTS
- NixOS module for deployment - NixOS modules for deployment
- CLI for manual operations - CLI for manual operations
- Comprehensive test suite - Comprehensive test suite
@@ -32,8 +43,10 @@ The first MCP server provides search and query capabilities for NixOS configurat
``` ```
labmcp/ labmcp/
├── cmd/ ├── cmd/
── nixos-options/ ── nixos-options/
└── main.go # CLI entry point └── main.go # NixOS options CLI
│ └── hm-options/
│ └── main.go # Home Manager options CLI
├── internal/ ├── internal/
│ ├── database/ │ ├── database/
│ │ ├── interface.go # Store interface │ │ ├── interface.go # Store interface
@@ -42,7 +55,7 @@ labmcp/
│ │ ├── sqlite.go # SQLite implementation │ │ ├── sqlite.go # SQLite implementation
│ │ └── *_test.go # Database tests │ │ └── *_test.go # Database tests
│ ├── mcp/ │ ├── mcp/
│ │ ├── server.go # MCP server core │ │ ├── server.go # MCP server core + ServerConfig
│ │ ├── handlers.go # Tool implementations │ │ ├── handlers.go # Tool implementations
│ │ ├── types.go # Protocol types │ │ ├── types.go # Protocol types
│ │ ├── transport.go # Transport interface │ │ ├── transport.go # Transport interface
@@ -50,14 +63,21 @@ labmcp/
│ │ ├── transport_http.go # HTTP/SSE transport │ │ ├── transport_http.go # HTTP/SSE transport
│ │ ├── session.go # HTTP session management │ │ ├── session.go # HTTP session management
│ │ └── *_test.go # MCP tests │ │ └── *_test.go # MCP tests
── nixos/ ── options/
── indexer.go # Nixpkgs indexing ── indexer.go # Shared Indexer interface
├── parser.go # options.json parsing ├── nixos/
│ │ ├── indexer.go # Nixpkgs indexing
│ │ ├── parser.go # options.json parsing (shared)
│ │ ├── types.go # Channel aliases, extensions
│ │ └── *_test.go # Indexer tests
│ └── homemanager/
│ ├── indexer.go # Home Manager indexing
│ ├── types.go # Channel aliases, extensions │ ├── types.go # Channel aliases, extensions
│ └── *_test.go # Indexer tests │ └── *_test.go # Indexer tests
├── nix/ ├── nix/
│ ├── module.nix # NixOS module │ ├── module.nix # NixOS module for nixos-options
── package.nix # Nix package definition ── hm-options-module.nix # NixOS module for hm-options
│ └── package.nix # Parameterized Nix package
├── testdata/ ├── testdata/
│ └── options-sample.json # Test fixture │ └── options-sample.json # Test fixture
├── flake.nix ├── flake.nix
@@ -70,14 +90,14 @@ labmcp/
## MCP Tools ## MCP Tools
All tools are implemented and functional: Both servers provide the same 6 tools:
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `search_options` | Full-text search across option names and descriptions | | `search_options` | Full-text search across option names and descriptions |
| `get_option` | Get full details for a specific option with children | | `get_option` | Get full details for a specific option with children |
| `get_file` | Fetch source file contents from indexed nixpkgs | | `get_file` | Fetch source file contents from indexed repository |
| `index_revision` | Index a nixpkgs revision (by hash or channel name) | | `index_revision` | Index a revision (by hash or channel name) |
| `list_revisions` | List all indexed revisions | | `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision | | `delete_revision` | Delete an indexed revision |
@@ -90,7 +110,7 @@ All tools are implemented and functional:
- Batch operations for efficient indexing - Batch operations for efficient indexing
### Indexing ### Indexing
- Uses `nix-build` to evaluate NixOS options from any nixpkgs revision - Uses `nix-build` to evaluate options from any revision
- File indexing downloads tarball and stores allowed extensions (.nix, .json, .md, etc.) - File indexing downloads tarball and stores allowed extensions (.nix, .json, .md, etc.)
- File indexing enabled by default (use `--no-files` to skip) - File indexing enabled by default (use `--no-files` to skip)
- Skips already-indexed revisions (use `--force` to re-index) - Skips already-indexed revisions (use `--force` to re-index)
@@ -116,12 +136,10 @@ All tools are implemented and functional:
## CLI Commands ## CLI Commands
### nixos-options
```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
nixos-options serve --transport http \
--http-address 0.0.0.0:8080 \
--allowed-origins https://example.com # HTTP with custom config
nixos-options index <revision> # Index a nixpkgs revision nixos-options index <revision> # Index a nixpkgs revision
nixos-options index --force <r> # Force re-index existing revision nixos-options index --force <r> # Force re-index existing revision
nixos-options index --no-files # Skip file content indexing nixos-options index --no-files # Skip file content indexing
@@ -132,6 +150,26 @@ nixos-options delete <revision> # Delete indexed revision
nixos-options --version # Show version nixos-options --version # Show version
``` ```
### hm-options
```bash
hm-options serve # Run MCP server on STDIO (default)
hm-options serve --transport http # Run MCP server on HTTP
hm-options index <revision> # Index a home-manager revision
hm-options index --force <r> # Force re-index existing revision
hm-options index --no-files # Skip file content indexing
hm-options list # List indexed revisions
hm-options search <query> # Search options
hm-options get <option> # Get option details
hm-options delete <revision> # Delete indexed revision
hm-options --version # Show version
```
### Channel Aliases
**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.
## Notes for Claude ## Notes for Claude
### Development Workflow ### Development Workflow
@@ -140,6 +178,21 @@ nixos-options --version # Show version
- **Use `nix run` to run binaries** (e.g., `nix run .#nixos-options -- serve`) - **Use `nix run` to run binaries** (e.g., `nix run .#nixos-options -- serve`)
- File paths in responses should use format `path/to/file.go:123` - File paths in responses should use format `path/to/file.go:123`
### Nix Build Requirement
**IMPORTANT**: When running `nix build`, `nix run`, or similar commands, new files must be tracked by git first. Nix flakes only see git-tracked files. If you create new files, run `git add <file>` before attempting nix operations.
### Version Bumping
Version bumps should be done once per feature branch, not per commit. Rules:
- **Patch bump** (0.1.0 → 0.1.1): Changes to Go code within `internal/` that affect a program
- **Minor bump** (0.1.0 → 0.2.0): Changes to Go code outside `internal/` (e.g., `cmd/`)
- **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:
- `cmd/nixos-options/main.go`
- `cmd/hm-options/main.go`
- `internal/mcp/server.go` (in `DefaultNixOSConfig` and `DefaultHomeManagerConfig`)
- `nix/package.nix`
### User Preferences ### User Preferences
- User prefers PostgreSQL over SQLite (has homelab infrastructure) - User prefers PostgreSQL over SQLite (has homelab infrastructure)
- User values good test coverage and benchmarking - User values good test coverage and benchmarking
@@ -155,14 +208,25 @@ nix develop -c go test ./... -v
# Run benchmarks (requires nix-build) # Run benchmarks (requires nix-build)
nix develop -c go test -bench=. -benchtime=1x -timeout=30m ./internal/nixos/... nix develop -c go test -bench=. -benchtime=1x -timeout=30m ./internal/nixos/...
nix develop -c go test -bench=. -benchtime=1x -timeout=30m ./internal/homemanager/...
``` ```
### Building ### Building
```bash ```bash
# Build with nix # Build with nix
nix build nix build .#nixos-options
nix build .#hm-options
# Run directly # Run directly
nix run . -- serve nix run .#nixos-options -- serve
nix run . -- index nixos-unstable nix run .#hm-options -- serve
nix run .#nixos-options -- index nixos-unstable
nix run .#hm-options -- index hm-unstable
``` ```
### Indexing Performance
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)
- **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.

157
README.md
View File

@@ -2,16 +2,22 @@
A collection of Model Context Protocol (MCP) servers written in Go. A collection of Model Context Protocol (MCP) servers written in Go.
## NixOS Options MCP Server ## MCP Servers
### NixOS Options (`nixos-options`)
Search and query NixOS configuration options across multiple nixpkgs revisions. Designed to help Claude (and other MCP clients) answer questions about NixOS configuration. Search and query NixOS configuration options across multiple nixpkgs revisions. Designed to help Claude (and other MCP clients) answer questions about NixOS configuration.
### Features ### 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.
### Shared Features
- Full-text search across option names and descriptions - Full-text search across option names and descriptions
- Query specific options with type, default, example, and declarations - Query specific options with type, default, example, and declarations
- Index multiple nixpkgs revisions (by git hash or channel name) - Index multiple revisions (by git hash or channel name)
- Fetch nixpkgs module source files - Fetch module source files
- Support for PostgreSQL and SQLite backends - Support for PostgreSQL and SQLite backends
## Installation ## Installation
@@ -19,17 +25,20 @@ Search and query NixOS configuration options across multiple nixpkgs revisions.
### Using Nix Flakes ### Using Nix Flakes
```bash ```bash
# Build the package # Build the packages
nix build git+https://git.t-juice.club/torjus/labmcp nix build git+https://git.t-juice.club/torjus/labmcp#nixos-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 -- --help nix run git+https://git.t-juice.club/torjus/labmcp#nixos-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/nixos-options@latest
go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest
``` ```
## Usage ## Usage
@@ -47,40 +56,49 @@ Configure in your MCP client (e.g., Claude Desktop):
"env": { "env": {
"NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db" "NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db"
} }
} },
} "hm-options": {
} "command": "hm-options",
``` "args": ["serve"],
Alternatively, if you have Nix installed, you can use the flake directly without installing the package:
```json
{
"mcpServers": {
"nixos-options": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp", "--", "serve"],
"env": { "env": {
"NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db" "HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
} }
} }
} }
} }
``` ```
Then start the server: Alternatively, if you have Nix installed, you can use the flake directly without installing the packages:
```bash ```json
nixos-options serve {
"mcpServers": {
"nixos-options": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixos-options", "--", "serve"],
"env": {
"NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db"
}
},
"hm-options": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#hm-options", "--", "serve"],
"env": {
"HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
}
}
}
}
``` ```
### As MCP Server (HTTP) ### As MCP Server (HTTP)
The server can also run over HTTP with Server-Sent Events (SSE) for web-based MCP clients: Both 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 nixos-options serve --transport http
hm-options serve --transport http
# Custom address and CORS configuration # Custom address and CORS configuration
nixos-options serve --transport http \ nixos-options serve --transport http \
@@ -100,49 +118,54 @@ HTTP transport endpoints:
### CLI Examples ### CLI Examples
**Index a nixpkgs revision:** **Index a revision:**
```bash ```bash
# Index by channel name (includes file contents by default) # NixOS options - index by channel name
nixos-options index nixos-unstable nixos-options 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 nixos-options index e6eae2ee2110f3d31110d5c222cd395303343b08
# 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 nixos-options index --no-files nixos-unstable
hm-options index --no-files release-24.11
``` ```
**List indexed revisions:** **List indexed revisions:**
```bash ```bash
nixos-options list nixos-options list
hm-options list
``` ```
**Search for options:** **Search for options:**
```bash ```bash
# Basic search # NixOS options
nixos-options search nginx nixos-options search nginx
# Limit results
nixos-options search -n 10 postgresql nixos-options search -n 10 postgresql
# Search in specific revision # Home Manager options
nixos-options search -r nixos-unstable firewall hm-options search git
hm-options search -n 10 neovim
``` ```
**Get option details:** **Get option details:**
```bash ```bash
nixos-options get services.nginx.enable nixos-options get services.nginx.enable
nixos-options get services.postgresql.package hm-options get programs.git.enable
``` ```
**Delete an indexed revision:** **Delete an indexed revision:**
```bash ```bash
nixos-options delete nixos-23.11 nixos-options delete nixos-23.11
hm-options delete release-23.11
``` ```
## Configuration ## Configuration
@@ -151,7 +174,8 @@ nixos-options delete nixos-23.11
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `NIXOS_OPTIONS_DATABASE` | Database connection string | `sqlite://nixos-options.db` | | `NIXOS_OPTIONS_DATABASE` | Database connection string for nixos-options | `sqlite://nixos-options.db` |
| `HM_OPTIONS_DATABASE` | Database connection string for hm-options | `sqlite://hm-options.db` |
### Database Connection Strings ### Database Connection Strings
@@ -172,25 +196,27 @@ The database can also be specified via the `-d` or `--database` flag:
```bash ```bash
nixos-options -d "postgres://localhost/nixos" serve nixos-options -d "postgres://localhost/nixos" serve
nixos-options -d "sqlite://my.db" index nixos-unstable hm-options -d "sqlite://my.db" index hm-unstable
``` ```
## MCP Tools ## MCP Tools
When running as an MCP server, the following tools are available: Both servers provide the following tools:
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `search_options` | Search for options by name or description | | `search_options` | Search for options by name or description |
| `get_option` | Get full details for a specific option | | `get_option` | Get full details for a specific option |
| `get_file` | Fetch source file contents from nixpkgs | | `get_file` | Fetch source file contents from the repository |
| `index_revision` | Index a nixpkgs revision | | `index_revision` | Index a revision |
| `list_revisions` | List all indexed revisions | | `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision | | `delete_revision` | Delete an indexed revision |
## NixOS Module ## NixOS Modules
A NixOS module is provided for running the MCP server as a systemd service. NixOS modules are provided for running both MCP servers as systemd services.
### nixos-options
```nix ```nix
{ {
@@ -213,21 +239,46 @@ A NixOS module is provided for running the MCP server as a systemd service.
} }
``` ```
### hm-options
```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.hm-options-mcp
{
services.hm-options-mcp = {
enable = true;
indexOnStart = [ "hm-unstable" ];
};
}
];
};
};
}
```
### Module Options ### Module Options
Both modules have similar options. Shown here for `nixos-options-mcp` (replace with `hm-options-mcp` for Home Manager):
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `enable` | bool | `false` | Enable the service | | `enable` | bool | `false` | Enable the service |
| `package` | package | from flake | Package to use | | `package` | package | from flake | Package to use |
| `database.type` | enum | `"sqlite"` | `"sqlite"` or `"postgres"` | | `database.type` | enum | `"sqlite"` | `"sqlite"` or `"postgres"` |
| `database.name` | string | `"nixos-options.db"` | SQLite database filename | | `database.name` | string | `"*.db"` | SQLite database filename |
| `database.connectionString` | string | `""` | PostgreSQL connection URL (stored in Nix store) | | `database.connectionString` | string | `""` | PostgreSQL connection URL (stored in Nix store) |
| `database.connectionStringFile` | path | `null` | Path to file with PostgreSQL connection URL (recommended for secrets) | | `database.connectionStringFile` | path | `null` | Path to file with PostgreSQL connection URL (recommended for secrets) |
| `indexOnStart` | list of string | `[]` | Revisions to index on service start | | `indexOnStart` | list of string | `[]` | Revisions to index on service start |
| `user` | string | `"nixos-options-mcp"` | User to run the service as | | `user` | string | `"*-mcp"` | User to run the service as |
| `group` | string | `"nixos-options-mcp"` | Group to run the service as | | `group` | string | `"*-mcp"` | Group to run the service as |
| `dataDir` | path | `/var/lib/nixos-options-mcp` | Directory for data storage | | `dataDir` | path | `/var/lib/*-mcp` | Directory for data storage |
| `http.address` | string | `"127.0.0.1:8080"` | HTTP listen address | | `http.address` | string | `"127.0.0.1:808x"` | HTTP listen address |
| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path | | `http.endpoint` | string | `"/mcp"` | HTTP endpoint path |
| `http.allowedOrigins` | list of string | `[]` | Allowed CORS origins (empty = localhost only) | | `http.allowedOrigins` | list of string | `[]` | Allowed CORS origins (empty = localhost only) |
| `http.sessionTTL` | string | `"30m"` | Session timeout (Go duration format) | | `http.sessionTTL` | string | `"30m"` | Session timeout (Go duration format) |
@@ -238,21 +289,6 @@ A NixOS module is provided for running the MCP server as a systemd service.
### PostgreSQL Example ### PostgreSQL Example
Using `connectionString` (stored in Nix store - suitable for testing or non-sensitive setups):
```nix
{
services.nixos-options-mcp = {
enable = true;
database = {
type = "postgres";
connectionString = "postgres://nixos:nixos@localhost/nixos_options?sslmode=disable";
};
indexOnStart = [ "nixos-unstable" "nixos-24.11" ];
};
}
```
Using `connectionStringFile` (recommended for production with sensitive credentials): Using `connectionStringFile` (recommended for production with sensitive credentials):
```nix ```nix
@@ -286,6 +322,7 @@ go test -bench=. ./internal/database/...
# Build # Build
go build ./cmd/nixos-options go build ./cmd/nixos-options
go build ./cmd/hm-options
``` ```
## License ## License

521
cmd/hm-options/main.go Normal file
View File

@@ -0,0 +1,521 @@
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/homemanager"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/options"
)
const (
defaultDatabase = "sqlite://hm-options.db"
version = "0.1.1"
)
func main() {
app := &cli.App{
Name: "hm-options",
Usage: "MCP server for Home Manager options search and query",
Version: version,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "database",
Aliases: []string{"d"},
Usage: "Database connection string (postgres://... or sqlite://...)",
EnvVars: []string{"HM_OPTIONS_DATABASE"},
Value: defaultDatabase,
},
},
Commands: []*cli.Command{
{
Name: "serve",
Usage: "Run MCP server",
Flags: []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,
},
},
Action: func(c *cli.Context) error {
return runServe(c)
},
},
{
Name: "index",
Usage: "Index a home-manager revision",
ArgsUsage: "<revision>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "no-files",
Usage: "Skip indexing file contents (faster, disables get_file tool)",
},
&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(), !c.Bool("no-files"), c.Bool("force"))
},
},
{
Name: "list",
Usage: "List indexed revisions",
Action: func(c *cli.Context) error {
return runList(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 runSearch(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 runGet(c, c.Args().First())
},
},
{
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())
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
// 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 runServe(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()
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.DefaultHomeManagerConfig()
server := mcp.NewServer(store, logger, config)
indexer := homemanager.NewIndexer(store)
server.RegisterHandlers(indexer)
transport := c.String("transport")
switch transport {
case "stdio":
logger.Println("Starting MCP server on stdio...")
return server.Run(ctx, os.Stdin, os.Stdout)
case "http":
config := 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, config)
return httpTransport.Run(ctx)
default:
return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport)
}
}
func runIndex(c *cli.Context, revision string, indexFiles bool, force bool) 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()
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
indexer := homemanager.NewIndexer(store)
fmt.Printf("Indexing revision: %s\n", revision)
var result *options.IndexResult
if force {
result, err = indexer.ReindexRevision(ctx, revision)
} else {
result, err = indexer.IndexRevision(ctx, revision)
}
if err != nil {
return fmt.Errorf("indexing failed: %w", err)
}
if result.AlreadyIndexed {
fmt.Printf("Revision already indexed (%d options). Use --force to re-index.\n", result.OptionCount)
return nil
}
if dur, ok := result.Duration.(time.Duration); ok {
fmt.Printf("Indexed %d options in %s\n", result.OptionCount, dur)
} else {
fmt.Printf("Indexed %d options\n", result.OptionCount)
}
fmt.Printf("Git hash: %s\n", result.Revision.GitHash)
if result.Revision.ChannelName != "" {
fmt.Printf("Channel: %s\n", result.Revision.ChannelName)
}
if indexFiles {
fmt.Println("Indexing files...")
fileCount, err := indexer.IndexFiles(ctx, result.Revision.ID, result.Revision.GitHash)
if err != nil {
return fmt.Errorf("file indexing failed: %w", err)
}
fmt.Printf("Indexed %d files\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()
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 'hm-options index <revision>' to index a home-manager 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, Indexed: %s\n",
rev.OptionCount, rev.IndexedAt.Format("2006-01-02 15:04"))
}
return nil
}
func runSearch(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()
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Find revision
var rev *database.Revision
revisionArg := c.String("revision")
if revisionArg != "" {
rev, err = store.GetRevision(ctx, revisionArg)
if err != nil {
return fmt.Errorf("failed to get revision: %w", err)
}
if rev == nil {
rev, err = store.GetRevisionByChannel(ctx, revisionArg)
if err != nil {
return fmt.Errorf("failed to get revision: %w", err)
}
}
} else {
revisions, err := store.ListRevisions(ctx)
if err != nil {
return fmt.Errorf("failed to list revisions: %w", err)
}
if len(revisions) > 0 {
rev = revisions[0]
}
}
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 runGet(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()
if err := store.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Find revision
var rev *database.Revision
revisionArg := c.String("revision")
if revisionArg != "" {
rev, err = store.GetRevision(ctx, revisionArg)
if err != nil {
return fmt.Errorf("failed to get revision: %w", err)
}
if rev == nil {
rev, err = store.GetRevisionByChannel(ctx, revisionArg)
if err != nil {
return fmt.Errorf("failed to get revision: %w", err)
}
}
} else {
revisions, err := store.ListRevisions(ctx)
if err != nil {
return fmt.Errorf("failed to list revisions: %w", err)
}
if len(revisions) > 0 {
rev = revisions[0]
}
}
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
}
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()
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
}

View File

@@ -19,7 +19,7 @@ import (
const ( const (
defaultDatabase = "sqlite://nixos-options.db" defaultDatabase = "sqlite://nixos-options.db"
version = "0.1.0" version = "0.1.1"
) )
func main() { func main() {
@@ -197,7 +197,8 @@ func runServe(c *cli.Context) error {
} }
logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags)
server := mcp.NewServer(store, logger) config := mcp.DefaultNixOSConfig()
server := mcp.NewServer(store, logger, config)
indexer := nixos.NewIndexer(store) indexer := nixos.NewIndexer(store)
server.RegisterHandlers(indexer) server.RegisterHandlers(indexer)

View File

@@ -19,6 +19,13 @@
in in
{ {
nixos-options = pkgs.callPackage ./nix/package.nix { src = ./.; }; nixos-options = pkgs.callPackage ./nix/package.nix { src = ./.; };
hm-options = pkgs.callPackage ./nix/package.nix {
src = ./.;
pname = "hm-options-mcp";
subPackage = "cmd/hm-options";
mainProgram = "hm-options";
description = "MCP server for Home Manager options search and query";
};
default = self.packages.${system}.nixos-options; default = self.packages.${system}.nixos-options;
}); });
@@ -50,6 +57,10 @@
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;
}; };
hm-options-mcp = { pkgs, ... }: {
imports = [ ./nix/hm-options-module.nix ];
services.hm-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.hm-options;
};
default = self.nixosModules.nixos-options-mcp; default = self.nixosModules.nixos-options-mcp;
}; };
}; };

View File

@@ -0,0 +1,428 @@
package homemanager
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/nixos"
"git.t-juice.club/torjus/labmcp/internal/options"
)
// revisionPattern validates revision strings to prevent injection attacks.
// Allows: alphanumeric, hyphens, underscores, dots (for channel names like "release-24.11"
// and git hashes). Must be 1-64 characters.
var revisionPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
// Indexer handles indexing of home-manager revisions.
type Indexer struct {
store database.Store
httpClient *http.Client
}
// NewIndexer creates a new Home Manager indexer.
func NewIndexer(store database.Store) *Indexer {
return &Indexer{
store: store,
httpClient: &http.Client{
Timeout: 5 * time.Minute,
},
}
}
// IndexResult contains the results of an indexing operation.
type IndexResult struct {
Revision *database.Revision
OptionCount int
FileCount int
Duration time.Duration
AlreadyIndexed bool // True if revision was already indexed (skipped)
}
// 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
}
// IndexRevision indexes a home-manager revision by git hash or channel name.
func (idx *Indexer) IndexRevision(ctx context.Context, revision string) (*options.IndexResult, error) {
start := time.Now()
// Validate revision to prevent injection attacks
if err := ValidateRevision(revision); err != nil {
return nil, err
}
// Resolve channel names to git refs
ref := idx.ResolveRevision(revision)
// Check if already indexed
existing, err := idx.store.GetRevision(ctx, ref)
if err != nil {
return nil, fmt.Errorf("failed to check existing revision: %w", err)
}
if existing != nil {
return &options.IndexResult{
Revision: existing,
OptionCount: existing.OptionCount,
Duration: time.Since(start),
AlreadyIndexed: true,
}, nil
}
// Build options.json using nix
optionsPath, cleanup, err := idx.buildOptions(ctx, ref)
if err != nil {
return nil, fmt.Errorf("failed to build options: %w", err)
}
defer cleanup()
// Parse options.json (reuse nixos parser - same format)
optionsFile, err := os.Open(optionsPath)
if err != nil {
return nil, fmt.Errorf("failed to open options.json: %w", err)
}
defer optionsFile.Close()
opts, err := nixos.ParseOptions(optionsFile)
if err != nil {
return nil, fmt.Errorf("failed to parse options: %w", err)
}
// Get commit info
commitDate, err := idx.getCommitDate(ctx, ref)
if err != nil {
// Non-fatal, use current time
commitDate = time.Now()
}
// Create revision record
rev := &database.Revision{
GitHash: ref,
ChannelName: idx.GetChannelName(revision),
CommitDate: commitDate,
OptionCount: len(opts),
}
if err := idx.store.CreateRevision(ctx, rev); err != nil {
return nil, fmt.Errorf("failed to create revision: %w", err)
}
// Store options
if err := idx.storeOptions(ctx, rev.ID, opts); err != nil {
// Cleanup on failure
idx.store.DeleteRevision(ctx, rev.ID)
return nil, fmt.Errorf("failed to store options: %w", err)
}
return &options.IndexResult{
Revision: rev,
OptionCount: len(opts),
Duration: time.Since(start),
}, nil
}
// ReindexRevision forces re-indexing of a revision, deleting existing data first.
func (idx *Indexer) ReindexRevision(ctx context.Context, revision string) (*options.IndexResult, error) {
// Validate revision to prevent injection attacks
if err := ValidateRevision(revision); err != nil {
return nil, err
}
ref := idx.ResolveRevision(revision)
// Delete existing revision if present
existing, err := idx.store.GetRevision(ctx, ref)
if err != nil {
return nil, fmt.Errorf("failed to check existing revision: %w", err)
}
if existing != nil {
if err := idx.store.DeleteRevision(ctx, existing.ID); err != nil {
return nil, fmt.Errorf("failed to delete existing revision: %w", err)
}
}
// Now index fresh
return idx.IndexRevision(ctx, revision)
}
// buildOptions builds options.json for a home-manager revision.
func (idx *Indexer) buildOptions(ctx context.Context, ref string) (string, func(), error) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "hm-options-*")
if err != nil {
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
}
cleanup := func() {
os.RemoveAll(tmpDir)
}
// Build options.json using nix-build
// This evaluates the Home Manager options from the specified revision
nixExpr := fmt.Sprintf(`
let
hm = builtins.fetchTarball {
url = "https://github.com/nix-community/home-manager/archive/%s.tar.gz";
};
nixpkgs = builtins.fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz";
};
pkgs = import nixpkgs { config = {}; };
lib = import (hm + "/modules/lib/stdlib-extended.nix") pkgs.lib;
docs = import (hm + "/docs") { inherit pkgs lib; release = "24.11"; isReleaseBranch = false; };
in docs.options.json
`, ref)
cmd := exec.CommandContext(ctx, "nix-build", "--no-out-link", "-E", nixExpr)
cmd.Dir = tmpDir
output, err := cmd.Output()
if err != nil {
cleanup()
if exitErr, ok := err.(*exec.ExitError); ok {
return "", nil, fmt.Errorf("nix-build failed: %s", string(exitErr.Stderr))
}
return "", nil, fmt.Errorf("nix-build failed: %w", err)
}
// The output is the store path containing share/doc/home-manager/options.json
storePath := strings.TrimSpace(string(output))
optionsPath := filepath.Join(storePath, "share", "doc", "home-manager", "options.json")
if _, err := os.Stat(optionsPath); err != nil {
cleanup()
return "", nil, fmt.Errorf("options.json not found at %s", optionsPath)
}
return optionsPath, cleanup, nil
}
// storeOptions stores parsed options in the database.
func (idx *Indexer) storeOptions(ctx context.Context, revisionID int64, opts map[string]*nixos.ParsedOption) error {
// Prepare batch of options
dbOpts := make([]*database.Option, 0, len(opts))
declsByName := make(map[string][]*database.Declaration)
for name, opt := range opts {
dbOpt := &database.Option{
RevisionID: revisionID,
Name: name,
ParentPath: database.ParentPath(name),
Type: opt.Type,
DefaultValue: opt.Default,
Example: opt.Example,
Description: opt.Description,
ReadOnly: opt.ReadOnly,
}
dbOpts = append(dbOpts, dbOpt)
// Prepare declarations for this option
decls := make([]*database.Declaration, 0, len(opt.Declarations))
for _, path := range opt.Declarations {
decls = append(decls, &database.Declaration{
FilePath: path,
})
}
declsByName[name] = decls
}
// Store options in batches
batchSize := 1000
for i := 0; i < len(dbOpts); i += batchSize {
end := i + batchSize
if end > len(dbOpts) {
end = len(dbOpts)
}
batch := dbOpts[i:end]
if err := idx.store.CreateOptionsBatch(ctx, batch); err != nil {
return fmt.Errorf("failed to store options batch: %w", err)
}
}
// Store declarations
for _, opt := range dbOpts {
decls := declsByName[opt.Name]
for _, decl := range decls {
decl.OptionID = opt.ID
}
if len(decls) > 0 {
if err := idx.store.CreateDeclarationsBatch(ctx, decls); err != nil {
return fmt.Errorf("failed to store declarations for %s: %w", opt.Name, err)
}
}
}
return nil
}
// getCommitDate gets the commit date for a git ref.
func (idx *Indexer) getCommitDate(ctx context.Context, ref string) (time.Time, error) {
// Use GitHub API to get commit info
url := fmt.Sprintf("https://api.github.com/repos/nix-community/home-manager/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()
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
}
// ResolveRevision resolves a channel name or ref to a git ref.
func (idx *Indexer) ResolveRevision(revision string) string {
// Check if it's a known channel alias
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 ""
}
// IndexFiles indexes files from a home-manager tarball.
// This is a separate operation that can be run after IndexRevision.
func (idx *Indexer) IndexFiles(ctx context.Context, revisionID int64, ref string) (int, error) {
// Download home-manager tarball
url := fmt.Sprintf("https://github.com/nix-community/home-manager/archive/%s.tar.gz", ref)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
resp, err := idx.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to download tarball: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("download failed with status %d", resp.StatusCode)
}
// Extract and index files
gz, err := gzip.NewReader(resp.Body)
if err != nil {
return 0, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
count := 0
batch := make([]*database.File, 0, 100)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return count, fmt.Errorf("tar read error: %w", err)
}
// Skip directories
if header.Typeflag != tar.TypeReg {
continue
}
// Check file extension
ext := filepath.Ext(header.Name)
if !AllowedExtensions[ext] {
continue
}
// Skip very large files (> 1MB)
if header.Size > 1024*1024 {
continue
}
// Remove the top-level directory (home-manager-<hash>/)
path := header.Name
if i := strings.Index(path, "/"); i >= 0 {
path = path[i+1:]
}
// Read content
content, err := io.ReadAll(tr)
if err != nil {
continue
}
file := &database.File{
RevisionID: revisionID,
FilePath: path,
Extension: ext,
Content: string(content),
}
batch = append(batch, file)
count++
// Store in batches
if len(batch) >= 100 {
if err := idx.store.CreateFilesBatch(ctx, batch); err != nil {
return count, fmt.Errorf("failed to store files batch: %w", err)
}
batch = batch[:0]
}
}
// Store remaining files
if len(batch) > 0 {
if err := idx.store.CreateFilesBatch(ctx, batch); err != nil {
return count, fmt.Errorf("failed to store final files batch: %w", err)
}
}
return count, nil
}

View File

@@ -0,0 +1,256 @@
package homemanager
import (
"context"
"os/exec"
"testing"
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
)
// TestHomeManagerRevision is a known release branch for testing.
const TestHomeManagerRevision = "release-24.11"
// TestValidateRevision tests the revision validation function.
func TestValidateRevision(t *testing.T) {
tests := []struct {
name string
revision string
wantErr bool
}{
// Valid cases
{"valid git hash", "abc123def456abc123def456abc123def456abc1", false},
{"valid short hash", "abc123d", false},
{"valid channel name", "hm-unstable", false},
{"valid release", "release-24.11", false},
{"valid master", "master", false},
{"valid underscore", "some_branch", false},
{"valid mixed", "release-24.05_beta", false},
// Invalid cases - injection attempts
{"injection semicolon", "foo; rm -rf /", true},
{"injection quotes", `"; builtins.readFile /etc/passwd; "`, true},
{"injection backticks", "foo`whoami`", true},
{"injection dollar", "foo$(whoami)", true},
{"injection newline", "foo\nbar", true},
{"injection space", "foo bar", true},
{"injection slash", "foo/bar", true},
{"injection backslash", "foo\\bar", true},
{"injection pipe", "foo|bar", true},
{"injection ampersand", "foo&bar", true},
{"injection redirect", "foo>bar", true},
{"injection less than", "foo<bar", true},
{"injection curly braces", "foo{bar}", true},
{"injection parens", "foo(bar)", true},
{"injection brackets", "foo[bar]", true},
// Edge cases
{"empty string", "", true},
{"too long", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
{"just dots", "...", false}, // dots are allowed, path traversal is handled elsewhere
{"single char", "a", false},
{"max length 64", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
{"65 chars", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRevision(tt.revision)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateRevision(%q) error = %v, wantErr %v", tt.revision, err, tt.wantErr)
}
})
}
}
// TestResolveRevision tests channel alias resolution.
func TestResolveRevision(t *testing.T) {
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
indexer := NewIndexer(store)
tests := []struct {
input string
expected string
}{
{"hm-unstable", "master"},
{"hm-stable", "release-24.11"},
{"master", "master"},
{"release-24.11", "release-24.11"},
{"release-24.05", "release-24.05"},
{"release-23.11", "release-23.11"},
{"abc123def", "abc123def"}, // Git hash passes through
{"unknown-channel", "unknown-channel"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := indexer.ResolveRevision(tt.input)
if result != tt.expected {
t.Errorf("ResolveRevision(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestGetChannelName tests channel name lookup.
func TestGetChannelName(t *testing.T) {
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
indexer := NewIndexer(store)
tests := []struct {
input string
expected string
}{
{"hm-unstable", "hm-unstable"},
{"hm-stable", "hm-stable"},
{"master", "master"}, // "master" is both an alias and a ref
{"release-24.11", "release-24.11"},
{"abc123def", ""}, // Git hash has no channel name
{"unknown", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := indexer.GetChannelName(tt.input)
if result != tt.expected {
t.Errorf("GetChannelName(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// BenchmarkIndexRevision benchmarks indexing a full home-manager revision.
// This is a slow benchmark that requires nix to be installed.
// Run with: go test -bench=BenchmarkIndexRevision -benchtime=1x -timeout=30m ./internal/homemanager/...
func BenchmarkIndexRevision(b *testing.B) {
// Check if nix-build is available
if _, err := exec.LookPath("nix-build"); err != nil {
b.Skip("nix-build not found, skipping indexer benchmark")
}
// Use in-memory SQLite for the benchmark
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
b.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.Initialize(ctx); err != nil {
b.Fatalf("Failed to initialize store: %v", err)
}
indexer := NewIndexer(store)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Delete any existing revision first (for repeated runs)
if rev, _ := store.GetRevision(ctx, TestHomeManagerRevision); rev != nil {
store.DeleteRevision(ctx, rev.ID)
}
result, err := indexer.IndexRevision(ctx, TestHomeManagerRevision)
if err != nil {
b.Fatalf("IndexRevision failed: %v", err)
}
b.ReportMetric(float64(result.OptionCount), "options")
b.ReportMetric(float64(result.Duration.(time.Duration).Milliseconds()), "ms")
}
}
// TestIndexRevision is an integration test for the indexer.
// Run with: go test -run=TestIndexRevision -timeout=30m ./internal/homemanager/...
func TestIndexRevision(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Check if nix-build is available
if _, err := exec.LookPath("nix-build"); err != nil {
t.Skip("nix-build not found, skipping indexer test")
}
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.Initialize(ctx); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
indexer := NewIndexer(store)
t.Logf("Indexing home-manager revision %s...", TestHomeManagerRevision)
result, err := indexer.IndexRevision(ctx, TestHomeManagerRevision)
if err != nil {
t.Fatalf("IndexRevision failed: %v", err)
}
t.Logf("Indexed %d options in %s", result.OptionCount, result.Duration)
// Verify we got a reasonable number of options (Home Manager has hundreds)
if result.OptionCount < 100 {
t.Errorf("Expected at least 100 options, got %d", result.OptionCount)
}
// Verify revision was stored
rev, err := store.GetRevision(ctx, TestHomeManagerRevision)
if err != nil {
t.Fatalf("GetRevision failed: %v", err)
}
if rev == nil {
t.Fatal("Revision not found after indexing")
}
if rev.OptionCount != result.OptionCount {
t.Errorf("Stored option count %d != result count %d", rev.OptionCount, result.OptionCount)
}
// Test searching for git options (programs.git is a common HM option)
options, err := store.SearchOptions(ctx, rev.ID, "git", database.SearchFilters{Limit: 10})
if err != nil {
t.Fatalf("SearchOptions failed: %v", err)
}
if len(options) == 0 {
t.Error("Expected to find git options")
}
t.Logf("Found %d git options", len(options))
// Test getting a specific option
opt, err := store.GetOption(ctx, rev.ID, "programs.git.enable")
if err != nil {
t.Fatalf("GetOption failed: %v", err)
}
if opt == nil {
t.Error("programs.git.enable not found")
} else {
t.Logf("programs.git.enable: type=%s", opt.Type)
if opt.Type != "boolean" {
t.Errorf("Expected type 'boolean', got %q", opt.Type)
}
}
// Test getting children
children, err := store.GetChildren(ctx, rev.ID, "programs.git")
if err != nil {
t.Fatalf("GetChildren failed: %v", err)
}
if len(children) == 0 {
t.Error("Expected programs.git to have children")
}
t.Logf("programs.git has %d direct children", len(children))
}

View File

@@ -0,0 +1,24 @@
// Package homemanager contains types and logic specific to Home Manager options.
package homemanager
// ChannelAliases maps friendly channel names to their git branch/ref patterns.
var ChannelAliases = map[string]string{
"hm-unstable": "master",
"hm-stable": "release-24.11",
"master": "master",
"release-24.11": "release-24.11",
"release-24.05": "release-24.05",
"release-23.11": "release-23.11",
"release-23.05": "release-23.05",
}
// AllowedExtensions is the default set of file extensions to index.
var AllowedExtensions = map[string]bool{
".nix": true,
".json": true,
".md": true,
".txt": true,
".toml": true,
".yaml": true,
".yml": true,
}

View File

@@ -9,11 +9,11 @@ import (
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/database" "git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/nixos" "git.t-juice.club/torjus/labmcp/internal/options"
) )
// RegisterHandlers registers all tool handlers on the server. // RegisterHandlers registers all tool handlers on the server.
func (s *Server) RegisterHandlers(indexer *nixos.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
s.tools["get_file"] = s.handleGetFile s.tools["get_file"] = s.handleGetFile
@@ -213,7 +213,7 @@ func (s *Server) handleGetFile(ctx context.Context, args map[string]interface{})
} }
// makeIndexHandler creates the index_revision handler with the indexer. // makeIndexHandler creates the index_revision handler with the indexer.
func (s *Server) makeIndexHandler(indexer *nixos.Indexer) ToolHandler { func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revision, _ := args["revision"].(string) revision, _ := args["revision"].(string)
if revision == "" { if revision == "" {
@@ -252,7 +252,10 @@ func (s *Server) makeIndexHandler(indexer *nixos.Indexer) ToolHandler {
} }
sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount)) sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount))
sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount)) sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount))
sb.WriteString(fmt.Sprintf("Duration: %s\n", result.Duration.Round(time.Millisecond))) // Handle Duration which may be time.Duration or interface{}
if dur, ok := result.Duration.(time.Duration); ok {
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
}
return CallToolResult{ return CallToolResult{
Content: []Content{TextContent(sb.String())}, Content: []Content{TextContent(sb.String())},
@@ -316,8 +319,12 @@ func (s *Server) handleDeleteRevision(ctx context.Context, args map[string]inter
// resolveRevision resolves a revision string to a Revision object. // resolveRevision resolves a revision string to a Revision object.
func (s *Server) resolveRevision(ctx context.Context, revision string) (*database.Revision, error) { func (s *Server) resolveRevision(ctx context.Context, revision string) (*database.Revision, error) {
if revision == "" { if revision == "" {
// Try to find a default revision // Try to find a default revision using config
rev, err := s.store.GetRevisionByChannel(ctx, "nixos-stable") defaultChannel := s.config.DefaultChannel
if defaultChannel == "" {
defaultChannel = "nixos-stable" // fallback for backwards compatibility
}
rev, err := s.store.GetRevisionByChannel(ctx, defaultChannel)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -10,9 +10,62 @@ import (
"git.t-juice.club/torjus/labmcp/internal/database" "git.t-juice.club/torjus/labmcp/internal/database"
) )
// ServerConfig contains configuration for the MCP server.
type ServerConfig struct {
// Name is the server name reported in initialization.
Name string
// Version is the server version.
Version string
// Instructions are the server instructions sent to clients.
Instructions string
// DefaultChannel is the default channel to use when no revision is specified.
DefaultChannel string
// SourceName is the name of the source repository (e.g., "nixpkgs", "home-manager").
SourceName string
}
// DefaultNixOSConfig returns the default configuration for NixOS options server.
func DefaultNixOSConfig() ServerConfig {
return ServerConfig{
Name: "nixos-options",
Version: "0.1.1",
DefaultChannel: "nixos-stable",
SourceName: "nixpkgs",
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:
1. Read the flake.lock file to find the nixpkgs "rev" field
2. Call index_revision with that git hash to index options for that specific version
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures option documentation matches the nixpkgs version the project actually uses.`,
}
}
// DefaultHomeManagerConfig returns the default configuration for Home Manager options server.
func DefaultHomeManagerConfig() ServerConfig {
return ServerConfig{
Name: "hm-options",
Version: "0.1.1",
DefaultChannel: "hm-stable",
SourceName: "home-manager",
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:
1. Read the flake.lock file to find the home-manager "rev" field
2. Call index_revision with that git hash to index options for that specific version
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures option documentation matches the home-manager version the project actually uses.`,
}
}
// Server is an MCP server that handles JSON-RPC requests. // Server is an MCP server that handles JSON-RPC requests.
type Server struct { type Server struct {
store database.Store store database.Store
config ServerConfig
tools map[string]ToolHandler tools map[string]ToolHandler
initialized bool initialized bool
logger *log.Logger logger *log.Logger
@@ -21,13 +74,14 @@ type Server struct {
// ToolHandler is a function that handles a tool call. // ToolHandler is a function that handles a tool call.
type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error)
// NewServer creates a new MCP server. // NewServer creates a new MCP server with the given configuration.
func NewServer(store database.Store, logger *log.Logger) *Server { func NewServer(store database.Store, logger *log.Logger, config ServerConfig) *Server {
if logger == nil { if logger == nil {
logger = log.New(io.Discard, "", 0) logger = log.New(io.Discard, "", 0)
} }
s := &Server{ s := &Server{
store: store, store: store,
config: config,
tools: make(map[string]ToolHandler), tools: make(map[string]ToolHandler),
logger: logger, logger: logger,
} }
@@ -126,18 +180,10 @@ func (s *Server) handleInitialize(req *Request) *Response {
}, },
}, },
ServerInfo: Implementation{ ServerInfo: Implementation{
Name: "nixos-options", Name: s.config.Name,
Version: "0.1.0", Version: s.config.Version,
}, },
Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options. Instructions: s.config.Instructions,
If the current project contains a flake.lock file, you can index the exact nixpkgs revision used by the project:
1. Read the flake.lock file to find the nixpkgs "rev" field
2. Call index_revision with that git hash to index options for that specific version
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures option documentation matches the nixpkgs version the project actually uses.`,
} }
return &Response{ return &Response{
@@ -159,10 +205,27 @@ 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 {
// Determine naming based on source
optionType := "NixOS"
sourceRepo := "nixpkgs"
exampleOption := "services.nginx.enable"
exampleNamespace := "services.nginx"
exampleFilePath := "nixos/modules/services/web-servers/nginx/default.nix"
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
if s.config.SourceName == "home-manager" {
optionType = "Home Manager"
sourceRepo = "home-manager"
exampleOption = "programs.git.enable"
exampleNamespace = "programs.git"
exampleFilePath = "modules/programs/git.nix"
exampleChannels = "'hm-unstable', 'release-24.11'"
}
return []Tool{ return []Tool{
{ {
Name: "search_options", Name: "search_options",
Description: "Search for NixOS configuration options by name or description", Description: fmt.Sprintf("Search for %s configuration options by name or description", optionType),
InputSchema: InputSchema{ InputSchema: InputSchema{
Type: "object", Type: "object",
Properties: map[string]Property{ Properties: map[string]Property{
@@ -172,7 +235,7 @@ func (s *Server) getToolDefinitions() []Tool {
}, },
"revision": { "revision": {
Type: "string", Type: "string",
Description: "Git hash or channel name (e.g., 'nixos-unstable'). Uses default if not specified.", Description: fmt.Sprintf("Git hash or channel name (e.g., %s). Uses default if not specified.", exampleChannels),
}, },
"type": { "type": {
Type: "string", Type: "string",
@@ -180,7 +243,7 @@ func (s *Server) getToolDefinitions() []Tool {
}, },
"namespace": { "namespace": {
Type: "string", Type: "string",
Description: "Filter by namespace prefix (e.g., 'services.nginx')", Description: fmt.Sprintf("Filter by namespace prefix (e.g., '%s')", exampleNamespace),
}, },
"limit": { "limit": {
Type: "integer", Type: "integer",
@@ -193,13 +256,13 @@ func (s *Server) getToolDefinitions() []Tool {
}, },
{ {
Name: "get_option", Name: "get_option",
Description: "Get full details for a specific NixOS option including its children", Description: fmt.Sprintf("Get full details for a specific %s option including its children", optionType),
InputSchema: InputSchema{ InputSchema: InputSchema{
Type: "object", Type: "object",
Properties: map[string]Property{ Properties: map[string]Property{
"name": { "name": {
Type: "string", Type: "string",
Description: "Full option path (e.g., 'services.nginx.enable')", Description: fmt.Sprintf("Full option path (e.g., '%s')", exampleOption),
}, },
"revision": { "revision": {
Type: "string", Type: "string",
@@ -216,13 +279,13 @@ func (s *Server) getToolDefinitions() []Tool {
}, },
{ {
Name: "get_file", Name: "get_file",
Description: "Fetch the contents of a file from nixpkgs", Description: fmt.Sprintf("Fetch the contents of a file from %s", sourceRepo),
InputSchema: InputSchema{ InputSchema: InputSchema{
Type: "object", Type: "object",
Properties: map[string]Property{ Properties: map[string]Property{
"path": { "path": {
Type: "string", Type: "string",
Description: "File path relative to nixpkgs root (e.g., 'nixos/modules/services/web-servers/nginx/default.nix')", Description: fmt.Sprintf("File path relative to %s root (e.g., '%s')", sourceRepo, exampleFilePath),
}, },
"revision": { "revision": {
Type: "string", Type: "string",
@@ -234,13 +297,13 @@ func (s *Server) getToolDefinitions() []Tool {
}, },
{ {
Name: "index_revision", Name: "index_revision",
Description: "Index a nixpkgs revision to make its options searchable", Description: fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo),
InputSchema: InputSchema{ InputSchema: InputSchema{
Type: "object", Type: "object",
Properties: map[string]Property{ Properties: map[string]Property{
"revision": { "revision": {
Type: "string", Type: "string",
Description: "Git hash (full or short) or channel name (e.g., 'nixos-unstable', 'nixos-24.05')", Description: fmt.Sprintf("Git hash (full or short) or channel name (e.g., %s)", exampleChannels),
}, },
}, },
Required: []string{"revision"}, Required: []string{"revision"},
@@ -248,7 +311,7 @@ func (s *Server) getToolDefinitions() []Tool {
}, },
{ {
Name: "list_revisions", Name: "list_revisions",
Description: "List all indexed nixpkgs revisions", Description: fmt.Sprintf("List all indexed %s revisions", sourceRepo),
InputSchema: InputSchema{ InputSchema: InputSchema{
Type: "object", Type: "object",
Properties: map[string]Property{}, Properties: map[string]Property{},

View File

@@ -14,7 +14,7 @@ import (
func TestServerInitialize(t *testing.T) { func TestServerInitialize(t *testing.T) {
store := setupTestStore(t) store := setupTestStore(t)
server := NewServer(store, nil) server := NewServer(store, nil, DefaultNixOSConfig())
input := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` input := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`
@@ -41,7 +41,7 @@ func TestServerInitialize(t *testing.T) {
func TestServerToolsList(t *testing.T) { func TestServerToolsList(t *testing.T) {
store := setupTestStore(t) store := setupTestStore(t)
server := NewServer(store, nil) server := NewServer(store, nil, DefaultNixOSConfig())
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
@@ -93,7 +93,7 @@ func TestServerToolsList(t *testing.T) {
func TestServerMethodNotFound(t *testing.T) { func TestServerMethodNotFound(t *testing.T) {
store := setupTestStore(t) store := setupTestStore(t)
server := NewServer(store, nil) server := NewServer(store, nil, DefaultNixOSConfig())
input := `{"jsonrpc":"2.0","id":1,"method":"unknown/method"}` input := `{"jsonrpc":"2.0","id":1,"method":"unknown/method"}`
@@ -110,7 +110,7 @@ func TestServerMethodNotFound(t *testing.T) {
func TestServerParseError(t *testing.T) { func TestServerParseError(t *testing.T) {
store := setupTestStore(t) store := setupTestStore(t)
server := NewServer(store, nil) server := NewServer(store, nil, DefaultNixOSConfig())
input := `not valid json` input := `not valid json`
@@ -127,7 +127,7 @@ func TestServerParseError(t *testing.T) {
func TestServerNotification(t *testing.T) { func TestServerNotification(t *testing.T) {
store := setupTestStore(t) store := setupTestStore(t)
server := NewServer(store, nil) server := NewServer(store, nil, DefaultNixOSConfig())
// Notification (no response expected) // Notification (no response expected)
input := `{"jsonrpc":"2.0","method":"notifications/initialized"}` input := `{"jsonrpc":"2.0","method":"notifications/initialized"}`
@@ -254,7 +254,7 @@ func setupTestStore(t *testing.T) database.Store {
func setupTestServer(t *testing.T, store database.Store) *Server { func setupTestServer(t *testing.T, store database.Store) *Server {
t.Helper() t.Helper()
server := NewServer(store, nil) server := NewServer(store, nil, DefaultNixOSConfig())
indexer := nixos.NewIndexer(store) indexer := nixos.NewIndexer(store)
server.RegisterHandlers(indexer) server.RegisterHandlers(indexer)

View File

@@ -15,7 +15,7 @@ import (
// testHTTPTransport creates a transport with a test server // testHTTPTransport creates a transport with a test server
func testHTTPTransport(t *testing.T, config HTTPConfig) (*HTTPTransport, *httptest.Server) { func testHTTPTransport(t *testing.T, config HTTPConfig) (*HTTPTransport, *httptest.Server) {
// Use a mock store // Use a mock store
server := NewServer(nil, log.New(io.Discard, "", 0)) server := NewServer(nil, log.New(io.Discard, "", 0), DefaultNixOSConfig())
if config.SessionTTL == 0 { if config.SessionTTL == 0 {
config.SessionTTL = 30 * time.Minute config.SessionTTL = 30 * time.Minute
@@ -459,7 +459,7 @@ func TestHTTPTransportSSEKeepalive(t *testing.T) {
} }
func TestHTTPTransportSSEKeepaliveDisabled(t *testing.T) { func TestHTTPTransportSSEKeepaliveDisabled(t *testing.T) {
server := NewServer(nil, log.New(io.Discard, "", 0)) server := NewServer(nil, log.New(io.Discard, "", 0), DefaultNixOSConfig())
config := HTTPConfig{ config := HTTPConfig{
SSEKeepAlive: -1, // Explicitly disabled SSEKeepAlive: -1, // Explicitly disabled
} }
@@ -545,7 +545,7 @@ func TestHTTPTransportOptionsRequest(t *testing.T) {
} }
func TestHTTPTransportDefaultConfig(t *testing.T) { func TestHTTPTransportDefaultConfig(t *testing.T) {
server := NewServer(nil, log.New(io.Discard, "", 0)) server := NewServer(nil, log.New(io.Discard, "", 0), DefaultNixOSConfig())
transport := NewHTTPTransport(server, HTTPConfig{}) transport := NewHTTPTransport(server, HTTPConfig{})
// Verify defaults are applied // Verify defaults are applied
@@ -581,7 +581,7 @@ func TestHTTPTransportDefaultConfig(t *testing.T) {
} }
func TestHTTPTransportCustomConfig(t *testing.T) { func TestHTTPTransportCustomConfig(t *testing.T) {
server := NewServer(nil, log.New(io.Discard, "", 0)) server := NewServer(nil, log.New(io.Discard, "", 0), DefaultNixOSConfig())
config := HTTPConfig{ config := HTTPConfig{
Address: "0.0.0.0:9090", Address: "0.0.0.0:9090",
Endpoint: "/api/mcp", Endpoint: "/api/mcp",

View File

@@ -16,6 +16,7 @@ import (
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/database" "git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/options"
) )
// revisionPattern validates revision strings to prevent injection attacks. // revisionPattern validates revision strings to prevent injection attacks.
@@ -40,13 +41,8 @@ func NewIndexer(store database.Store) *Indexer {
} }
// IndexResult contains the results of an indexing operation. // IndexResult contains the results of an indexing operation.
type IndexResult struct { // Deprecated: Use options.IndexResult instead.
Revision *database.Revision type IndexResult = options.IndexResult
OptionCount int
FileCount int
Duration time.Duration
AlreadyIndexed bool // True if revision was already indexed (skipped)
}
// ValidateRevision checks if a revision string is safe to use. // ValidateRevision checks if a revision string is safe to use.
// Returns an error if the revision contains potentially dangerous characters. // Returns an error if the revision contains potentially dangerous characters.
@@ -305,7 +301,30 @@ func (idx *Indexer) getCommitDate(ctx context.Context, ref string) (time.Time, e
return commit.Commit.Committer.Date, nil return commit.Commit.Committer.Date, nil
} }
// resolveRevision resolves a channel name or ref to a git ref. // ResolveRevision resolves a channel name or ref to a git ref.
func (idx *Indexer) ResolveRevision(revision string) string {
// Check if it's a known channel alias
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 ""
}
// resolveRevision is a helper that calls the method.
func resolveRevision(revision string) string { func resolveRevision(revision string) string {
// Check if it's a known channel alias // Check if it's a known channel alias
if ref, ok := ChannelAliases[revision]; ok { if ref, ok := ChannelAliases[revision]; ok {
@@ -314,7 +333,7 @@ func resolveRevision(revision string) string {
return revision return revision
} }
// getChannelName returns the channel name if the revision matches one. // getChannelName is a helper that returns the channel name.
func getChannelName(revision string) string { func getChannelName(revision string) string {
if _, ok := ChannelAliases[revision]; ok { if _, ok := ChannelAliases[revision]; ok {
return revision return revision

View File

@@ -99,7 +99,9 @@ func BenchmarkIndexRevision(b *testing.B) {
} }
b.ReportMetric(float64(result.OptionCount), "options") b.ReportMetric(float64(result.OptionCount), "options")
b.ReportMetric(float64(result.Duration.Milliseconds()), "ms") if dur, ok := result.Duration.(time.Duration); ok {
b.ReportMetric(float64(dur.Milliseconds()), "ms")
}
} }
} }
@@ -146,7 +148,9 @@ func BenchmarkIndexRevisionWithFiles(b *testing.B) {
fileDuration := time.Since(fileStart) fileDuration := time.Since(fileStart)
b.ReportMetric(float64(result.OptionCount), "options") b.ReportMetric(float64(result.OptionCount), "options")
b.ReportMetric(float64(result.Duration.Milliseconds()), "options_ms") if dur, ok := result.Duration.(time.Duration); ok {
b.ReportMetric(float64(dur.Milliseconds()), "options_ms")
}
b.ReportMetric(float64(fileCount), "files") b.ReportMetric(float64(fileCount), "files")
b.ReportMetric(float64(fileDuration.Milliseconds()), "files_ms") b.ReportMetric(float64(fileDuration.Milliseconds()), "files_ms")
} }

View File

@@ -40,7 +40,7 @@ type ParsedOption struct {
// optionJSON is the internal structure for parsing options.json entries. // optionJSON is the internal structure for parsing options.json entries.
type optionJSON struct { type optionJSON struct {
Declarations []string `json:"declarations"` Declarations json.RawMessage `json:"declarations"` // Can be []string or []{name, url}
Default json.RawMessage `json:"default,omitempty"` Default json.RawMessage `json:"default,omitempty"`
Description interface{} `json:"description"` // Can be string or object Description interface{} `json:"description"` // Can be string or object
Example json.RawMessage `json:"example,omitempty"` Example json.RawMessage `json:"example,omitempty"`
@@ -58,11 +58,8 @@ func parseOption(name string, data json.RawMessage) (*ParsedOption, error) {
// Handle description which can be a string or an object with _type: "mdDoc" // Handle description which can be a string or an object with _type: "mdDoc"
description := extractDescription(opt.Description) description := extractDescription(opt.Description)
// Convert declarations to relative paths // Parse declarations - can be []string (NixOS) or []{name, url} (Home Manager)
declarations := make([]string, 0, len(opt.Declarations)) declarations := parseDeclarations(opt.Declarations)
for _, d := range opt.Declarations {
declarations = append(declarations, normalizeDeclarationPath(d))
}
return &ParsedOption{ return &ParsedOption{
Name: name, Name: name,
@@ -75,6 +72,39 @@ func parseOption(name string, data json.RawMessage) (*ParsedOption, error) {
}, nil }, nil
} }
// parseDeclarations handles both NixOS format ([]string) and Home Manager format ([]{name, url}).
func parseDeclarations(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
// Try []string first (NixOS format)
var stringDecls []string
if err := json.Unmarshal(raw, &stringDecls); err == nil {
result := make([]string, 0, len(stringDecls))
for _, d := range stringDecls {
result = append(result, normalizeDeclarationPath(d))
}
return result
}
// Try []{name, url} format (Home Manager format)
var objectDecls []struct {
Name string `json:"name"`
URL string `json:"url"`
}
if err := json.Unmarshal(raw, &objectDecls); err == nil {
result := make([]string, 0, len(objectDecls))
for _, d := range objectDecls {
// Use name field, normalize the path
result = append(result, normalizeDeclarationPath(d.Name))
}
return result
}
return nil
}
// extractDescription extracts the description string from various formats. // extractDescription extracts the description string from various formats.
func extractDescription(desc interface{}) string { func extractDescription(desc interface{}) string {
switch v := desc.(type) { switch v := desc.(type) {
@@ -93,16 +123,29 @@ func extractDescription(desc interface{}) string {
return "" return ""
} }
// normalizeDeclarationPath converts a full store path to a relative nixpkgs path. // normalizeDeclarationPath converts a full store path to a relative path.
// Input: "/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix" // NixOS input: "/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"
// Output: "nixos/modules/services/web-servers/nginx/default.nix" // NixOS output: "nixos/modules/services/web-servers/nginx/default.nix"
// HM input: "<home-manager/modules/programs/git.nix>"
// HM output: "modules/programs/git.nix"
func normalizeDeclarationPath(path string) string { func normalizeDeclarationPath(path string) string {
// Look for common prefixes and strip them // Handle Home Manager format: <home-manager/path> or <nixpkgs/path>
if len(path) > 2 && path[0] == '<' && path[len(path)-1] == '>' {
inner := path[1 : len(path)-1]
// Strip the prefix (home-manager/, nixpkgs/, etc.)
if idx := findSubstring(inner, "/"); idx >= 0 {
return inner[idx+1:]
}
return inner
}
// Look for common prefixes and strip them (NixOS store paths)
markers := []string{ markers := []string{
"/nixos/", "/nixos/",
"/pkgs/", "/pkgs/",
"/lib/", "/lib/",
"/maintainers/", "/maintainers/",
"/modules/", // For home-manager paths
} }
for _, marker := range markers { for _, marker := range markers {

View File

@@ -0,0 +1,37 @@
// Package options provides shared types and interfaces for options indexers.
package options
import (
"context"
"git.t-juice.club/torjus/labmcp/internal/database"
)
// IndexResult contains the results of an indexing operation.
type IndexResult struct {
Revision *database.Revision
OptionCount int
FileCount int
Duration interface{} // time.Duration - kept as interface to avoid import cycle
AlreadyIndexed bool // True if revision was already indexed (skipped)
}
// Indexer is the interface for options indexers.
// Both NixOS and Home Manager indexers implement this interface.
type Indexer interface {
// IndexRevision indexes a revision by git hash or channel name.
// Returns AlreadyIndexed=true if the revision was already indexed.
IndexRevision(ctx context.Context, revision string) (*IndexResult, error)
// ReindexRevision forces re-indexing of a revision, deleting existing data first.
ReindexRevision(ctx context.Context, revision string) (*IndexResult, error)
// IndexFiles indexes files from the source repository tarball.
IndexFiles(ctx context.Context, revisionID int64, ref string) (int, error)
// ResolveRevision resolves a channel name or ref to a git ref.
ResolveRevision(revision string) string
// GetChannelName returns the channel name if the revision matches one.
GetChannelName(revision string) string
}

261
nix/hm-options-module.nix Normal file
View File

@@ -0,0 +1,261 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.hm-options-mcp;
# 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;
in
{
options.services.hm-options-mcp = {
enable = lib.mkEnableOption "Home Manager Options MCP server";
package = lib.mkPackageOption pkgs "hm-options-mcp" { };
user = lib.mkOption {
type = lib.types.str;
default = "hm-options-mcp";
description = "User account under which the service runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "hm-options-mcp";
description = "Group under which the service runs.";
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/hm-options-mcp";
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 = "hm-options.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/hm_options?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/hm_options?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/hm-options-mcp-db";
};
};
indexOnStart = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "hm-unstable" "release-24.11" ];
description = ''
List of home-manager revisions to index on service start.
Can be channel names (hm-unstable) or git hashes.
Indexing is skipped if the revision is already indexed.
'';
};
http = {
address = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1:8081";
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.";
};
};
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the MCP HTTP server.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.database.type == "sqlite"
|| cfg.database.connectionString != ""
|| cfg.database.connectionStringFile != null;
message = "services.hm-options-mcp.database: when using postgres backend, either connectionString or connectionStringFile must be set";
}
{
assertion = cfg.database.connectionString == "" || cfg.database.connectionStringFile == null;
message = "services.hm-options-mcp.database: connectionString and connectionStringFile are mutually exclusive";
}
{
assertion = !cfg.http.tls.enable || (cfg.http.tls.certFile != null && cfg.http.tls.keyFile != null);
message = "services.hm-options-mcp.http.tls: both certFile and keyFile must be set when TLS is enabled";
}
];
users.users.${cfg.user} = lib.mkIf (cfg.user == "hm-options-mcp") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
description = "Home Manager Options MCP server user";
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "hm-options-mcp") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
];
systemd.services.hm-options-mcp = {
description = "Home Manager Options MCP Server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]
++ lib.optional (cfg.database.type == "postgres") "postgresql.service";
environment = lib.mkIf (!useConnectionStringFile) {
HM_OPTIONS_DATABASE = databaseUrl;
};
path = [ cfg.package ];
script = let
indexCommands = lib.optionalString (cfg.indexOnStart != []) ''
${lib.concatMapStringsSep "\n" (rev: ''
echo "Indexing revision: ${rev}"
hm-options index "${rev}" || true
'') cfg.indexOnStart}
'';
# Build HTTP transport flags
httpFlags = lib.concatStringsSep " " ([
"--transport http"
"--http-address '${cfg.http.address}'"
"--http-endpoint '${cfg.http.endpoint}'"
"--session-ttl '${cfg.http.sessionTTL}'"
] ++ lib.optionals (cfg.http.allowedOrigins != []) (
map (origin: "--allowed-origins '${origin}'") cfg.http.allowedOrigins
) ++ lib.optionals cfg.http.tls.enable [
"--tls-cert '${cfg.http.tls.certFile}'"
"--tls-key '${cfg.http.tls.keyFile}'"
]);
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 HM_OPTIONS_DATABASE="$(cat "${cfg.database.connectionStringFile}")"
${indexCommands}
exec hm-options serve ${httpFlags}
'' else ''
${indexCommands}
exec hm-options 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/hm-options-mcp") "hm-options-mcp";
};
};
# Open firewall for HTTP port if configured
networking.firewall = lib.mkIf cfg.openFirewall (let
# Extract port from address (format: "host:port" or ":port")
addressParts = lib.splitString ":" cfg.http.address;
port = lib.toInt (lib.last addressParts);
in {
allowedTCPPorts = [ port ];
});
};
}

View File

@@ -1,26 +1,29 @@
{ lib, buildGoModule, makeWrapper, nix, src }: { lib, buildGoModule, makeWrapper, nix, src
, pname ? "nixos-options-mcp"
, subPackage ? "cmd/nixos-options"
, mainProgram ? "nixos-options"
, description ? "MCP server for NixOS options search and query"
}:
buildGoModule { buildGoModule {
pname = "nixos-options-mcp"; inherit pname src;
version = "0.1.0"; version = "0.1.1";
inherit src;
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY="; vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
subPackages = [ "cmd/nixos-options" ]; subPackages = [ subPackage ];
nativeBuildInputs = [ makeWrapper ]; nativeBuildInputs = [ makeWrapper ];
postInstall = '' postInstall = ''
wrapProgram $out/bin/nixos-options \ wrapProgram $out/bin/${mainProgram} \
--prefix PATH : ${lib.makeBinPath [ nix ]} --prefix PATH : ${lib.makeBinPath [ nix ]}
''; '';
meta = with lib; { meta = with lib; {
description = "MCP server for NixOS options search and query"; inherit description mainProgram;
homepage = "https://git.t-juice.club/torjus/labmcp"; homepage = "https://git.t-juice.club/torjus/labmcp";
license = licenses.mit; license = licenses.mit;
maintainers = [ ]; maintainers = [ ];
mainProgram = "nixos-options";
}; };
} }