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:
108
CLAUDE.md
108
CLAUDE.md
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -21,9 +32,9 @@ The first MCP server provides search and query capabilities for NixOS configurat
|
||||
## Project Status
|
||||
|
||||
**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
|
||||
- NixOS module for deployment
|
||||
- NixOS modules for deployment
|
||||
- CLI for manual operations
|
||||
- Comprehensive test suite
|
||||
|
||||
@@ -32,8 +43,10 @@ The first MCP server provides search and query capabilities for NixOS configurat
|
||||
```
|
||||
labmcp/
|
||||
├── cmd/
|
||||
│ └── nixos-options/
|
||||
│ └── main.go # CLI entry point
|
||||
│ ├── nixos-options/
|
||||
│ │ └── main.go # NixOS options CLI
|
||||
│ └── hm-options/
|
||||
│ └── main.go # Home Manager options CLI
|
||||
├── internal/
|
||||
│ ├── database/
|
||||
│ │ ├── interface.go # Store interface
|
||||
@@ -42,7 +55,7 @@ labmcp/
|
||||
│ │ ├── sqlite.go # SQLite implementation
|
||||
│ │ └── *_test.go # Database tests
|
||||
│ ├── mcp/
|
||||
│ │ ├── server.go # MCP server core
|
||||
│ │ ├── server.go # MCP server core + ServerConfig
|
||||
│ │ ├── handlers.go # Tool implementations
|
||||
│ │ ├── types.go # Protocol types
|
||||
│ │ ├── transport.go # Transport interface
|
||||
@@ -50,14 +63,21 @@ labmcp/
|
||||
│ │ ├── transport_http.go # HTTP/SSE transport
|
||||
│ │ ├── session.go # HTTP session management
|
||||
│ │ └── *_test.go # MCP tests
|
||||
│ └── nixos/
|
||||
│ ├── indexer.go # Nixpkgs indexing
|
||||
│ ├── parser.go # options.json parsing
|
||||
│ ├── options/
|
||||
│ │ └── indexer.go # Shared Indexer interface
|
||||
│ ├── 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
|
||||
│ └── *_test.go # Indexer tests
|
||||
├── nix/
|
||||
│ ├── module.nix # NixOS module
|
||||
│ └── package.nix # Nix package definition
|
||||
│ ├── module.nix # NixOS module for nixos-options
|
||||
│ ├── hm-options-module.nix # NixOS module for hm-options
|
||||
│ └── package.nix # Parameterized Nix package
|
||||
├── testdata/
|
||||
│ └── options-sample.json # Test fixture
|
||||
├── flake.nix
|
||||
@@ -70,14 +90,14 @@ labmcp/
|
||||
|
||||
## MCP Tools
|
||||
|
||||
All tools are implemented and functional:
|
||||
Both servers provide the same 6 tools:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_options` | Full-text search across option names and descriptions |
|
||||
| `get_option` | Get full details for a specific option with children |
|
||||
| `get_file` | Fetch source file contents from indexed nixpkgs |
|
||||
| `index_revision` | Index a nixpkgs revision (by hash or channel name) |
|
||||
| `get_file` | Fetch source file contents from indexed repository |
|
||||
| `index_revision` | Index a revision (by hash or channel name) |
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
@@ -90,7 +110,7 @@ All tools are implemented and functional:
|
||||
- Batch operations for efficient 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 enabled by default (use `--no-files` to skip)
|
||||
- Skips already-indexed revisions (use `--force` to re-index)
|
||||
@@ -116,12 +136,10 @@ All tools are implemented and functional:
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### nixos-options
|
||||
```bash
|
||||
nixos-options serve # Run MCP server on STDIO (default)
|
||||
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 --force <r> # Force re-index existing revision
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
### Development Workflow
|
||||
@@ -140,6 +178,21 @@ nixos-options --version # Show version
|
||||
- **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`
|
||||
|
||||
### 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 prefers PostgreSQL over SQLite (has homelab infrastructure)
|
||||
- User values good test coverage and benchmarking
|
||||
@@ -155,14 +208,25 @@ nix develop -c go test ./... -v
|
||||
|
||||
# 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/homemanager/...
|
||||
```
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Build with nix
|
||||
nix build
|
||||
nix build .#nixos-options
|
||||
nix build .#hm-options
|
||||
|
||||
# Run directly
|
||||
nix run . -- serve
|
||||
nix run . -- index nixos-unstable
|
||||
nix run .#nixos-options -- serve
|
||||
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.
|
||||
|
||||
139
README.md
139
README.md
@@ -2,16 +2,22 @@
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
- Query specific options with type, default, example, and declarations
|
||||
- Index multiple nixpkgs revisions (by git hash or channel name)
|
||||
- Fetch nixpkgs module source files
|
||||
- Index multiple revisions (by git hash or channel name)
|
||||
- Fetch module source files
|
||||
- Support for PostgreSQL and SQLite backends
|
||||
|
||||
## Installation
|
||||
@@ -19,17 +25,20 @@ Search and query NixOS configuration options across multiple nixpkgs revisions.
|
||||
### Using Nix Flakes
|
||||
|
||||
```bash
|
||||
# Build the package
|
||||
nix build git+https://git.t-juice.club/torjus/labmcp
|
||||
# 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#hm-options
|
||||
|
||||
# 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
|
||||
|
||||
```bash
|
||||
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
|
||||
@@ -47,40 +56,49 @@ Configure in your MCP client (e.g., Claude Desktop):
|
||||
"env": {
|
||||
"NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db"
|
||||
}
|
||||
},
|
||||
"hm-options": {
|
||||
"command": "hm-options",
|
||||
"args": ["serve"],
|
||||
"env": {
|
||||
"HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, if you have Nix installed, you can use the flake directly without installing the package:
|
||||
Alternatively, if you have Nix installed, you can use the flake directly without installing the packages:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nixos-options": {
|
||||
"command": "nix",
|
||||
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp", "--", "serve"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then start the server:
|
||||
|
||||
```bash
|
||||
nixos-options serve
|
||||
```
|
||||
|
||||
### 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
|
||||
# Start HTTP server on default address (127.0.0.1:8080)
|
||||
nixos-options serve --transport http
|
||||
hm-options serve --transport http
|
||||
|
||||
# Custom address and CORS configuration
|
||||
nixos-options serve --transport http \
|
||||
@@ -100,49 +118,54 @@ HTTP transport endpoints:
|
||||
|
||||
### CLI Examples
|
||||
|
||||
**Index a nixpkgs revision:**
|
||||
**Index a revision:**
|
||||
|
||||
```bash
|
||||
# Index by channel name (includes file contents by default)
|
||||
# NixOS options - index by channel name
|
||||
nixos-options index nixos-unstable
|
||||
|
||||
# Home Manager options - index by channel name
|
||||
hm-options index hm-unstable
|
||||
|
||||
# Index by git hash
|
||||
nixos-options index e6eae2ee2110f3d31110d5c222cd395303343b08
|
||||
|
||||
# Index without file contents (faster, disables get_file tool)
|
||||
nixos-options index --no-files nixos-unstable
|
||||
hm-options index --no-files release-24.11
|
||||
```
|
||||
|
||||
**List indexed revisions:**
|
||||
|
||||
```bash
|
||||
nixos-options list
|
||||
hm-options list
|
||||
```
|
||||
|
||||
**Search for options:**
|
||||
|
||||
```bash
|
||||
# Basic search
|
||||
# NixOS options
|
||||
nixos-options search nginx
|
||||
|
||||
# Limit results
|
||||
nixos-options search -n 10 postgresql
|
||||
|
||||
# Search in specific revision
|
||||
nixos-options search -r nixos-unstable firewall
|
||||
# Home Manager options
|
||||
hm-options search git
|
||||
hm-options search -n 10 neovim
|
||||
```
|
||||
|
||||
**Get option details:**
|
||||
|
||||
```bash
|
||||
nixos-options get services.nginx.enable
|
||||
nixos-options get services.postgresql.package
|
||||
hm-options get programs.git.enable
|
||||
```
|
||||
|
||||
**Delete an indexed revision:**
|
||||
|
||||
```bash
|
||||
nixos-options delete nixos-23.11
|
||||
hm-options delete release-23.11
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -151,7 +174,8 @@ nixos-options delete nixos-23.11
|
||||
|
||||
| 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
|
||||
|
||||
@@ -172,25 +196,27 @@ The database can also be specified via the `-d` or `--database` flag:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
When running as an MCP server, the following tools are available:
|
||||
Both servers provide the following tools:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_options` | Search for options by name or description |
|
||||
| `get_option` | Get full details for a specific option |
|
||||
| `get_file` | Fetch source file contents from nixpkgs |
|
||||
| `index_revision` | Index a nixpkgs revision |
|
||||
| `get_file` | Fetch source file contents from the repository |
|
||||
| `index_revision` | Index a revision |
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `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
|
||||
{
|
||||
@@ -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
|
||||
|
||||
Both modules have similar options. Shown here for `nixos-options-mcp` (replace with `hm-options-mcp` for Home Manager):
|
||||
|
||||
| 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 | `"nixos-options.db"` | SQLite database filename |
|
||||
| `database.name` | string | `"*.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 |
|
||||
| `user` | string | `"nixos-options-mcp"` | User to run the service as |
|
||||
| `group` | string | `"nixos-options-mcp"` | Group to run the service as |
|
||||
| `dataDir` | path | `/var/lib/nixos-options-mcp` | Directory for data storage |
|
||||
| `http.address` | string | `"127.0.0.1:8080"` | HTTP listen address |
|
||||
| `user` | string | `"*-mcp"` | User to run the service as |
|
||||
| `group` | string | `"*-mcp"` | Group to run the service as |
|
||||
| `dataDir` | path | `/var/lib/*-mcp` | Directory for data storage |
|
||||
| `http.address` | string | `"127.0.0.1:808x"` | HTTP listen address |
|
||||
| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path |
|
||||
| `http.allowedOrigins` | list of string | `[]` | Allowed CORS origins (empty = localhost only) |
|
||||
| `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
|
||||
|
||||
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):
|
||||
|
||||
```nix
|
||||
@@ -286,6 +322,7 @@ go test -bench=. ./internal/database/...
|
||||
|
||||
# Build
|
||||
go build ./cmd/nixos-options
|
||||
go build ./cmd/hm-options
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
521
cmd/hm-options/main.go
Normal file
521
cmd/hm-options/main.go
Normal 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
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
const (
|
||||
defaultDatabase = "sqlite://nixos-options.db"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -197,7 +197,8 @@ func runServe(c *cli.Context) error {
|
||||
}
|
||||
|
||||
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)
|
||||
server.RegisterHandlers(indexer)
|
||||
|
||||
11
flake.nix
11
flake.nix
@@ -19,6 +19,13 @@
|
||||
in
|
||||
{
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -50,6 +57,10 @@
|
||||
imports = [ ./nix/module.nix ];
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
428
internal/homemanager/indexer.go
Normal file
428
internal/homemanager/indexer.go
Normal 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
|
||||
}
|
||||
256
internal/homemanager/indexer_test.go
Normal file
256
internal/homemanager/indexer_test.go
Normal 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))
|
||||
}
|
||||
24
internal/homemanager/types.go
Normal file
24
internal/homemanager/types.go
Normal 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,
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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["get_option"] = s.handleGetOption
|
||||
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.
|
||||
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) {
|
||||
revision, _ := args["revision"].(string)
|
||||
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("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{
|
||||
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.
|
||||
func (s *Server) resolveRevision(ctx context.Context, revision string) (*database.Revision, error) {
|
||||
if revision == "" {
|
||||
// Try to find a default revision
|
||||
rev, err := s.store.GetRevisionByChannel(ctx, "nixos-stable")
|
||||
// Try to find a default revision using config
|
||||
defaultChannel := s.config.DefaultChannel
|
||||
if defaultChannel == "" {
|
||||
defaultChannel = "nixos-stable" // fallback for backwards compatibility
|
||||
}
|
||||
rev, err := s.store.GetRevisionByChannel(ctx, defaultChannel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,9 +10,62 @@ import (
|
||||
"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.
|
||||
type Server struct {
|
||||
store database.Store
|
||||
config ServerConfig
|
||||
tools map[string]ToolHandler
|
||||
initialized bool
|
||||
logger *log.Logger
|
||||
@@ -21,13 +74,14 @@ type Server struct {
|
||||
// ToolHandler is a function that handles a tool call.
|
||||
type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error)
|
||||
|
||||
// NewServer creates a new MCP server.
|
||||
func NewServer(store database.Store, logger *log.Logger) *Server {
|
||||
// NewServer creates a new MCP server with the given configuration.
|
||||
func NewServer(store database.Store, logger *log.Logger, config ServerConfig) *Server {
|
||||
if logger == nil {
|
||||
logger = log.New(io.Discard, "", 0)
|
||||
}
|
||||
s := &Server{
|
||||
store: store,
|
||||
config: config,
|
||||
tools: make(map[string]ToolHandler),
|
||||
logger: logger,
|
||||
}
|
||||
@@ -126,18 +180,10 @@ func (s *Server) handleInitialize(req *Request) *Response {
|
||||
},
|
||||
},
|
||||
ServerInfo: Implementation{
|
||||
Name: "nixos-options",
|
||||
Version: "0.1.0",
|
||||
Name: s.config.Name,
|
||||
Version: s.config.Version,
|
||||
},
|
||||
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.`,
|
||||
Instructions: s.config.Instructions,
|
||||
}
|
||||
|
||||
return &Response{
|
||||
@@ -159,10 +205,27 @@ func (s *Server) handleToolsList(req *Request) *Response {
|
||||
|
||||
// getToolDefinitions returns the tool definitions.
|
||||
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{
|
||||
{
|
||||
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{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
@@ -172,7 +235,7 @@ func (s *Server) getToolDefinitions() []Tool {
|
||||
},
|
||||
"revision": {
|
||||
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: "string",
|
||||
@@ -180,7 +243,7 @@ func (s *Server) getToolDefinitions() []Tool {
|
||||
},
|
||||
"namespace": {
|
||||
Type: "string",
|
||||
Description: "Filter by namespace prefix (e.g., 'services.nginx')",
|
||||
Description: fmt.Sprintf("Filter by namespace prefix (e.g., '%s')", exampleNamespace),
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
@@ -193,13 +256,13 @@ func (s *Server) getToolDefinitions() []Tool {
|
||||
},
|
||||
{
|
||||
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{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"name": {
|
||||
Type: "string",
|
||||
Description: "Full option path (e.g., 'services.nginx.enable')",
|
||||
Description: fmt.Sprintf("Full option path (e.g., '%s')", exampleOption),
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
@@ -216,13 +279,13 @@ func (s *Server) getToolDefinitions() []Tool {
|
||||
},
|
||||
{
|
||||
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{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"path": {
|
||||
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": {
|
||||
Type: "string",
|
||||
@@ -234,13 +297,13 @@ func (s *Server) getToolDefinitions() []Tool {
|
||||
},
|
||||
{
|
||||
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{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"revision": {
|
||||
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"},
|
||||
@@ -248,7 +311,7 @@ func (s *Server) getToolDefinitions() []Tool {
|
||||
},
|
||||
{
|
||||
Name: "list_revisions",
|
||||
Description: "List all indexed nixpkgs revisions",
|
||||
Description: fmt.Sprintf("List all indexed %s revisions", sourceRepo),
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{},
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func TestServerInitialize(t *testing.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"}}}`
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestServerInitialize(t *testing.T) {
|
||||
|
||||
func TestServerToolsList(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
server := NewServer(store, nil)
|
||||
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||
|
||||
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestServerToolsList(t *testing.T) {
|
||||
|
||||
func TestServerMethodNotFound(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
server := NewServer(store, nil)
|
||||
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||
|
||||
input := `{"jsonrpc":"2.0","id":1,"method":"unknown/method"}`
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestServerMethodNotFound(t *testing.T) {
|
||||
|
||||
func TestServerParseError(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
server := NewServer(store, nil)
|
||||
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||
|
||||
input := `not valid json`
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestServerParseError(t *testing.T) {
|
||||
|
||||
func TestServerNotification(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
server := NewServer(store, nil)
|
||||
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||
|
||||
// Notification (no response expected)
|
||||
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 {
|
||||
t.Helper()
|
||||
|
||||
server := NewServer(store, nil)
|
||||
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||
indexer := nixos.NewIndexer(store)
|
||||
server.RegisterHandlers(indexer)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// testHTTPTransport creates a transport with a test server
|
||||
func testHTTPTransport(t *testing.T, config HTTPConfig) (*HTTPTransport, *httptest.Server) {
|
||||
// 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 {
|
||||
config.SessionTTL = 30 * time.Minute
|
||||
@@ -459,7 +459,7 @@ func TestHTTPTransportSSEKeepalive(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{
|
||||
SSEKeepAlive: -1, // Explicitly disabled
|
||||
}
|
||||
@@ -545,7 +545,7 @@ func TestHTTPTransportOptionsRequest(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{})
|
||||
|
||||
// Verify defaults are applied
|
||||
@@ -581,7 +581,7 @@ func TestHTTPTransportDefaultConfig(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{
|
||||
Address: "0.0.0.0:9090",
|
||||
Endpoint: "/api/mcp",
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
"git.t-juice.club/torjus/labmcp/internal/options"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type IndexResult struct {
|
||||
Revision *database.Revision
|
||||
OptionCount int
|
||||
FileCount int
|
||||
Duration time.Duration
|
||||
AlreadyIndexed bool // True if revision was already indexed (skipped)
|
||||
}
|
||||
// Deprecated: Use options.IndexResult instead.
|
||||
type IndexResult = options.IndexResult
|
||||
|
||||
// ValidateRevision checks if a revision string is safe to use.
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Check if it's a known channel alias
|
||||
if ref, ok := ChannelAliases[revision]; ok {
|
||||
@@ -314,7 +333,7 @@ func resolveRevision(revision string) string {
|
||||
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 {
|
||||
if _, ok := ChannelAliases[revision]; ok {
|
||||
return revision
|
||||
|
||||
@@ -99,7 +99,9 @@ func BenchmarkIndexRevision(b *testing.B) {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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(fileDuration.Milliseconds()), "files_ms")
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ type ParsedOption struct {
|
||||
|
||||
// optionJSON is the internal structure for parsing options.json entries.
|
||||
type optionJSON struct {
|
||||
Declarations []string `json:"declarations"`
|
||||
Declarations json.RawMessage `json:"declarations"` // Can be []string or []{name, url}
|
||||
Default json.RawMessage `json:"default,omitempty"`
|
||||
Description interface{} `json:"description"` // Can be string or object
|
||||
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"
|
||||
description := extractDescription(opt.Description)
|
||||
|
||||
// Convert declarations to relative paths
|
||||
declarations := make([]string, 0, len(opt.Declarations))
|
||||
for _, d := range opt.Declarations {
|
||||
declarations = append(declarations, normalizeDeclarationPath(d))
|
||||
}
|
||||
// Parse declarations - can be []string (NixOS) or []{name, url} (Home Manager)
|
||||
declarations := parseDeclarations(opt.Declarations)
|
||||
|
||||
return &ParsedOption{
|
||||
Name: name,
|
||||
@@ -75,6 +72,39 @@ func parseOption(name string, data json.RawMessage) (*ParsedOption, error) {
|
||||
}, 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.
|
||||
func extractDescription(desc interface{}) string {
|
||||
switch v := desc.(type) {
|
||||
@@ -93,16 +123,29 @@ func extractDescription(desc interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeDeclarationPath converts a full store path to a relative nixpkgs path.
|
||||
// Input: "/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"
|
||||
// Output: "nixos/modules/services/web-servers/nginx/default.nix"
|
||||
// normalizeDeclarationPath converts a full store path to a relative path.
|
||||
// NixOS input: "/nix/store/xxx-source/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 {
|
||||
// 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{
|
||||
"/nixos/",
|
||||
"/pkgs/",
|
||||
"/lib/",
|
||||
"/maintainers/",
|
||||
"/modules/", // For home-manager paths
|
||||
}
|
||||
|
||||
for _, marker := range markers {
|
||||
|
||||
37
internal/options/indexer.go
Normal file
37
internal/options/indexer.go
Normal 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
261
nix/hm-options-module.nix
Normal 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 ];
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
pname = "nixos-options-mcp";
|
||||
version = "0.1.0";
|
||||
inherit src;
|
||||
inherit pname src;
|
||||
version = "0.1.1";
|
||||
|
||||
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
|
||||
|
||||
subPackages = [ "cmd/nixos-options" ];
|
||||
subPackages = [ subPackage ];
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
||||
postInstall = ''
|
||||
wrapProgram $out/bin/nixos-options \
|
||||
wrapProgram $out/bin/${mainProgram} \
|
||||
--prefix PATH : ${lib.makeBinPath [ nix ]}
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "MCP server for NixOS options search and query";
|
||||
inherit description mainProgram;
|
||||
homepage = "https://git.t-juice.club/torjus/labmcp";
|
||||
license = licenses.mit;
|
||||
maintainers = [ ];
|
||||
mainProgram = "nixos-options";
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user