Compare commits

..

5 Commits

Author SHA1 Message Date
4ae92b4f85 chore: migrate module path from git.t-juice.club to code.t-juice.club
Update Go module path and all import references for Gitea to Forgejo
host migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:48:25 +01:00
4276ffbda5 feat: add optional basic auth support for Loki client
Some Loki deployments (e.g., behind a reverse proxy or Grafana Cloud)
require HTTP Basic Authentication. This adds optional --loki-username
and --loki-password flags (and corresponding env vars) to the
lab-monitoring server, along with NixOS module options for secure
credential management via systemd LoadCredential.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:32:10 +01:00
aff058dcc0 chore: update flake.lock
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:22:08 +01:00
dcaeb1f517 chore: remove unused gotools and go-tools from devShell
Both are redundant: staticcheck is covered by golangci-lint, and
gotools (goimports, etc.) is covered by gopls and golangci-lint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:20:48 +01:00
fd40e73f1b feat: add package indexing to MCP index_revision tool
The options server's index_revision now also indexes packages when running
under nixpkgs-search, matching the CLI behavior. The packages server gets
its own index_revision tool for standalone package indexing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:12:08 +01:00
27 changed files with 492 additions and 95 deletions

View File

@@ -25,6 +25,7 @@ Query Prometheus metrics, Alertmanager alerts, and Loki logs. Unlike other serve
- 8 core tools: list/get alerts, search metrics, get metadata, PromQL query, list targets, list/create silences
- 3 optional Loki tools (when `LOKI_URL` is set): query_logs, list_labels, list_label_values
- Configurable Prometheus, Alertmanager, and Loki URLs via flags or environment variables
- Optional basic auth for Loki (`LOKI_USERNAME`/`LOKI_PASSWORD`)
### Git Explorer (`git-explorer`)
Read-only access to git repository information. Designed for deployment verification.
@@ -45,7 +46,7 @@ The nixpkgs/options/hm servers share a database-backed architecture:
- **Build System**: Nix flakes
- **Databases**: PostgreSQL and SQLite (both fully supported)
- **Protocol**: MCP (Model Context Protocol) - JSON-RPC over STDIO or HTTP/SSE
- **Module Path**: `git.t-juice.club/torjus/labmcp`
- **Module Path**: `code.t-juice.club/torjus/labmcp`
## Project Status
@@ -144,7 +145,7 @@ labmcp/
| `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 repository |
| `index_revision` | Index a revision (by hash or channel name) |
| `index_revision` | Index a revision (options, files, and packages for nixpkgs) |
| `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision |
@@ -155,6 +156,7 @@ labmcp/
| `search_packages` | Full-text search across package names and descriptions |
| `get_package` | Get full details for a specific package by attr path |
| `get_file` | Fetch source file contents from nixpkgs |
| `index_revision` | Index a revision to make its packages searchable |
| `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision |

View File

@@ -59,25 +59,25 @@ Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-se
```bash
# Build the packages
nix build git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search
nix build git+https://git.t-juice.club/torjus/labmcp#hm-options
nix build git+https://git.t-juice.club/torjus/labmcp#lab-monitoring
nix build git+https://git.t-juice.club/torjus/labmcp#git-explorer
nix build git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search
nix build git+https://code.t-juice.club/torjus/labmcp#hm-options
nix build git+https://code.t-juice.club/torjus/labmcp#lab-monitoring
nix build git+https://code.t-juice.club/torjus/labmcp#git-explorer
# Or run directly
nix run git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search -- --help
nix run git+https://git.t-juice.club/torjus/labmcp#hm-options -- --help
nix run git+https://git.t-juice.club/torjus/labmcp#lab-monitoring -- --help
nix run git+https://git.t-juice.club/torjus/labmcp#git-explorer -- --help
nix run git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search -- --help
nix run git+https://code.t-juice.club/torjus/labmcp#hm-options -- --help
nix run git+https://code.t-juice.club/torjus/labmcp#lab-monitoring -- --help
nix run git+https://code.t-juice.club/torjus/labmcp#git-explorer -- --help
```
### From Source
```bash
go install git.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest
go install git.t-juice.club/torjus/labmcp/cmd/lab-monitoring@latest
go install git.t-juice.club/torjus/labmcp/cmd/git-explorer@latest
go install code.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
go install code.t-juice.club/torjus/labmcp/cmd/hm-options@latest
go install code.t-juice.club/torjus/labmcp/cmd/lab-monitoring@latest
go install code.t-juice.club/torjus/labmcp/cmd/git-explorer@latest
```
## Usage
@@ -116,7 +116,9 @@ Configure in your MCP client (e.g., Claude Desktop):
"env": {
"PROMETHEUS_URL": "http://prometheus.example.com:9090",
"ALERTMANAGER_URL": "http://alertmanager.example.com:9093",
"LOKI_URL": "http://loki.example.com:3100"
"LOKI_URL": "http://loki.example.com:3100",
"LOKI_USERNAME": "optional-username",
"LOKI_PASSWORD": "optional-password"
}
},
"git-explorer": {
@@ -137,28 +139,28 @@ Alternatively, if you have Nix installed, you can use the flake directly without
"mcpServers": {
"nixpkgs-options": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "options", "serve"],
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "options", "serve"],
"env": {
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
}
},
"nixpkgs-packages": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "packages", "serve"],
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "packages", "serve"],
"env": {
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
}
},
"hm-options": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#hm-options", "--", "serve"],
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#hm-options", "--", "serve"],
"env": {
"HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
}
},
"lab-monitoring": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#lab-monitoring", "--", "serve"],
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#lab-monitoring", "--", "serve"],
"env": {
"PROMETHEUS_URL": "http://prometheus.example.com:9090",
"ALERTMANAGER_URL": "http://alertmanager.example.com:9093",
@@ -167,7 +169,7 @@ Alternatively, if you have Nix installed, you can use the flake directly without
},
"git-explorer": {
"command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#git-explorer", "--", "serve"],
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#git-explorer", "--", "serve"],
"env": {
"GIT_REPO_PATH": "/path/to/your/repo"
}
@@ -351,6 +353,8 @@ hm-options delete release-23.11
| `PROMETHEUS_URL` | Prometheus base URL for lab-monitoring | `http://localhost:9090` |
| `ALERTMANAGER_URL` | Alertmanager base URL for lab-monitoring | `http://localhost:9093` |
| `LOKI_URL` | Loki base URL for lab-monitoring (optional, enables log tools) | *(none)* |
| `LOKI_USERNAME` | Username for Loki basic auth (optional) | *(none)* |
| `LOKI_PASSWORD` | Password for Loki basic auth (optional) | *(none)* |
### Database Connection Strings
@@ -384,7 +388,7 @@ hm-options -d "sqlite://my.db" index hm-unstable
| `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 the repository |
| `index_revision` | Index a revision |
| `index_revision` | Index a revision (options, files, and packages for nixpkgs) |
| `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision |
@@ -395,7 +399,7 @@ hm-options -d "sqlite://my.db" index hm-unstable
| `search_packages` | Search for packages by name or description |
| `get_package` | Get full details for a specific package |
| `get_file` | Fetch source file contents from nixpkgs |
| `index_revision` | Index a revision |
| `index_revision` | Index a revision to make its packages searchable |
| `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision |
@@ -439,7 +443,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
```nix
{
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -476,7 +480,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
```nix
{
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -499,7 +503,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
```nix
{
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -524,7 +528,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
```nix
{
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -547,7 +551,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
```nix
{
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -605,6 +609,8 @@ Both `options.http` and `packages.http` also support:
| `prometheusUrl` | string | `"http://localhost:9090"` | Prometheus base URL |
| `alertmanagerUrl` | string | `"http://localhost:9093"` | Alertmanager base URL |
| `lokiUrl` | nullOr string | `null` | Loki base URL (enables log query tools when set) |
| `lokiUsername` | nullOr string | `null` | Username for Loki basic authentication |
| `lokiPasswordFile` | nullOr path | `null` | Path to file containing Loki password (uses systemd `LoadCredential`) |
| `enableSilences` | bool | `false` | Enable the create_silence tool (write operation) |
| `http.address` | string | `"127.0.0.1:8084"` | HTTP listen address |
| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path |

View File

@@ -11,8 +11,8 @@ import (
"github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/gitexplorer"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"code.t-juice.club/torjus/labmcp/internal/gitexplorer"
"code.t-juice.club/torjus/labmcp/internal/mcp"
)
const version = "0.1.0"

View File

@@ -12,10 +12,10 @@ import (
"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"
"code.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/homemanager"
"code.t-juice.club/torjus/labmcp/internal/mcp"
"code.t-juice.club/torjus/labmcp/internal/options"
)
const (

View File

@@ -11,11 +11,11 @@ import (
"github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/monitoring"
"code.t-juice.club/torjus/labmcp/internal/mcp"
"code.t-juice.club/torjus/labmcp/internal/monitoring"
)
const version = "0.3.0"
const version = "0.3.1"
func main() {
app := &cli.App{
@@ -40,6 +40,16 @@ func main() {
Usage: "Loki base URL (optional, enables log query tools)",
EnvVars: []string{"LOKI_URL"},
},
&cli.StringFlag{
Name: "loki-username",
Usage: "Username for Loki basic auth",
EnvVars: []string{"LOKI_USERNAME"},
},
&cli.StringFlag{
Name: "loki-password",
Usage: "Password for Loki basic auth",
EnvVars: []string{"LOKI_PASSWORD"},
},
},
Commands: []*cli.Command{
serveCommand(),
@@ -189,7 +199,11 @@ func runServe(c *cli.Context) error {
var loki *monitoring.LokiClient
if lokiURL := c.String("loki-url"); lokiURL != "" {
loki = monitoring.NewLokiClient(lokiURL)
loki = monitoring.NewLokiClient(monitoring.LokiClientOptions{
BaseURL: lokiURL,
Username: c.String("loki-username"),
Password: c.String("loki-password"),
})
}
config.InstructionsFunc = func() string {
@@ -432,7 +446,11 @@ func runLogs(c *cli.Context, logql string) error {
}
ctx := context.Background()
loki := monitoring.NewLokiClient(lokiURL)
loki := monitoring.NewLokiClient(monitoring.LokiClientOptions{
BaseURL: lokiURL,
Username: c.String("loki-username"),
Password: c.String("loki-password"),
})
now := time.Now()
start, err := parseCLITime(c.String("start"), now.Add(-time.Hour))
@@ -487,7 +505,11 @@ func runLabels(c *cli.Context) error {
}
ctx := context.Background()
loki := monitoring.NewLokiClient(lokiURL)
loki := monitoring.NewLokiClient(monitoring.LokiClientOptions{
BaseURL: lokiURL,
Username: c.String("loki-username"),
Password: c.String("loki-password"),
})
if label := c.String("values"); label != "" {
values, err := loki.LabelValues(ctx, label)

View File

@@ -12,9 +12,9 @@ import (
"github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/nixos"
"code.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/mcp"
"code.t-juice.club/torjus/labmcp/internal/nixos"
)
const (

View File

@@ -12,15 +12,15 @@ import (
"github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/nixos"
"git.t-juice.club/torjus/labmcp/internal/packages"
"code.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/mcp"
"code.t-juice.club/torjus/labmcp/internal/nixos"
"code.t-juice.club/torjus/labmcp/internal/packages"
)
const (
defaultDatabase = "sqlite://nixpkgs-search.db"
version = "0.3.0"
version = "0.4.0"
)
func main() {
@@ -310,7 +310,8 @@ func runOptionsServe(c *cli.Context) error {
server := mcp.NewServer(store, logger, config)
indexer := nixos.NewIndexer(store)
server.RegisterHandlers(indexer)
pkgIndexer := packages.NewIndexer(store)
server.RegisterHandlersWithPackages(indexer, pkgIndexer)
transport := c.String("transport")
switch transport {

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1770197578,
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
"lastModified": 1770841267,
"narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
"rev": "ec7c70d12ce2fc37cb92aff673dcdca89d187bae",
"type": "github"
},
"original": {

View File

@@ -59,8 +59,6 @@
buildInputs = with pkgs; [
go
gopls
gotools
go-tools
golangci-lint
govulncheck
postgresql

4
go.mod
View File

@@ -1,8 +1,9 @@
module git.t-juice.club/torjus/labmcp
module code.t-juice.club/torjus/labmcp
go 1.24
require (
github.com/go-git/go-git/v5 v5.16.4
github.com/lib/pq v1.10.9
github.com/urfave/cli/v2 v2.27.5
modernc.org/sqlite v1.34.4
@@ -19,7 +20,6 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect

35
go.sum
View File

@@ -5,6 +5,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
@@ -12,19 +16,28 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -36,21 +49,30 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@@ -61,6 +83,8 @@ github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQ
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
@@ -70,6 +94,8 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBi
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -82,21 +108,26 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=

View File

@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"code.t-juice.club/torjus/labmcp/internal/mcp"
)
// RegisterHandlers registers all git-explorer tool handlers on the MCP server.

View File

@@ -15,9 +15,9 @@ import (
"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"
"code.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/nixos"
"code.t-juice.club/torjus/labmcp/internal/options"
)
// revisionPattern validates revision strings to prevent injection attacks.

View File

@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/database"
)
// TestHomeManagerRevision is a known release branch for testing.

View File

@@ -8,18 +8,35 @@ import (
"strings"
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/options"
"git.t-juice.club/torjus/labmcp/internal/packages"
"code.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/options"
"code.t-juice.club/torjus/labmcp/internal/packages"
)
// RegisterHandlers registers all tool handlers on the server for options mode.
// Used by legacy nixos-options and hm-options servers (no package indexing).
func (s *Server) RegisterHandlers(indexer options.Indexer) {
s.registerOptionsHandlers(indexer, nil)
}
// RegisterHandlersWithPackages registers all tool handlers for options mode
// with additional package indexing support. When pkgIndexer is non-nil,
// index_revision will also index packages, and list_revisions will show package counts.
func (s *Server) RegisterHandlersWithPackages(indexer options.Indexer, pkgIndexer *packages.Indexer) {
s.registerOptionsHandlers(indexer, pkgIndexer)
}
// registerOptionsHandlers is the shared implementation for RegisterHandlers and RegisterHandlersWithPackages.
func (s *Server) registerOptionsHandlers(indexer options.Indexer, pkgIndexer *packages.Indexer) {
s.tools["search_options"] = s.handleSearchOptions
s.tools["get_option"] = s.handleGetOption
s.tools["get_file"] = s.handleGetFile
s.tools["index_revision"] = s.makeIndexHandler(indexer)
s.tools["list_revisions"] = s.handleListRevisions
s.tools["index_revision"] = s.makeIndexHandler(indexer, pkgIndexer)
if pkgIndexer != nil {
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
} else {
s.tools["list_revisions"] = s.handleListRevisions
}
s.tools["delete_revision"] = s.handleDeleteRevision
}
@@ -28,6 +45,7 @@ func (s *Server) RegisterPackageHandlers(pkgIndexer *packages.Indexer) {
s.tools["search_packages"] = s.handleSearchPackages
s.tools["get_package"] = s.handleGetPackage
s.tools["get_file"] = s.handleGetFile
s.tools["index_revision"] = s.makePackageIndexHandler(pkgIndexer)
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
s.tools["delete_revision"] = s.handleDeleteRevision
}
@@ -246,7 +264,8 @@ 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 options.Indexer) ToolHandler {
// If pkgIndexer is non-nil, it will also index packages after options and files.
func (s *Server) makeIndexHandler(indexer options.Indexer, pkgIndexer *packages.Indexer) ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revision, _ := args["revision"].(string)
if revision == "" {
@@ -278,6 +297,17 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
s.logger.Printf("Warning: file indexing failed: %v", err)
}
// Index packages if package indexer is available
var packageCount int
if pkgIndexer != nil {
pkgResult, pkgErr := pkgIndexer.IndexPackages(ctx, result.Revision.ID, result.Revision.GitHash)
if pkgErr != nil {
s.logger.Printf("Warning: package indexing failed: %v", pkgErr)
} else {
packageCount = pkgResult.PackageCount
}
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", result.Revision.GitHash))
if result.Revision.ChannelName != "" {
@@ -285,6 +315,9 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
}
sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount))
sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount))
if packageCount > 0 {
sb.WriteString(fmt.Sprintf("Packages: %d\n", packageCount))
}
// 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)))
@@ -296,6 +329,85 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
}
}
// makePackageIndexHandler creates an index_revision handler for the packages-only server.
// It creates a revision record if needed, then indexes packages.
func (s *Server) makePackageIndexHandler(pkgIndexer *packages.Indexer) ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revision, _ := args["revision"].(string)
if revision == "" {
return ErrorContent(fmt.Errorf("revision is required")), nil
}
if err := packages.ValidateRevision(revision); err != nil {
return ErrorContent(err), nil
}
// Resolve channel aliases to git ref
ref := pkgIndexer.ResolveRevision(revision)
// Check if revision already exists
rev, err := s.store.GetRevision(ctx, ref)
if err != nil {
return ErrorContent(fmt.Errorf("failed to check revision: %w", err)), nil
}
if rev == nil {
// Also try by channel name
rev, err = s.store.GetRevisionByChannel(ctx, revision)
if err != nil {
return ErrorContent(fmt.Errorf("failed to check revision: %w", err)), nil
}
}
if rev == nil {
// Create a new revision record
commitDate, _ := pkgIndexer.GetCommitDate(ctx, ref)
rev = &database.Revision{
GitHash: ref,
ChannelName: pkgIndexer.GetChannelName(revision),
CommitDate: commitDate,
}
if err := s.store.CreateRevision(ctx, rev); err != nil {
return ErrorContent(fmt.Errorf("failed to create revision: %w", err)), nil
}
}
// Check if packages are already indexed for this revision
if rev.PackageCount > 0 {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Revision already indexed: %s\n", rev.GitHash))
if rev.ChannelName != "" {
sb.WriteString(fmt.Sprintf("Channel: %s\n", rev.ChannelName))
}
sb.WriteString(fmt.Sprintf("Packages: %d\n", rev.PackageCount))
sb.WriteString(fmt.Sprintf("Indexed at: %s\n", rev.IndexedAt.Format("2006-01-02 15:04")))
return CallToolResult{
Content: []Content{TextContent(sb.String())},
}, nil
}
// Index packages
pkgResult, err := pkgIndexer.IndexPackages(ctx, rev.ID, rev.GitHash)
if err != nil {
return ErrorContent(fmt.Errorf("package indexing failed: %w", err)), nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", rev.GitHash))
if rev.ChannelName != "" {
sb.WriteString(fmt.Sprintf("Channel: %s\n", rev.ChannelName))
}
sb.WriteString(fmt.Sprintf("Packages: %d\n", pkgResult.PackageCount))
if dur, ok := pkgResult.Duration.(time.Duration); ok {
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
}
return CallToolResult{
Content: []Content{TextContent(sb.String())},
}, nil
}
}
// handleListRevisions handles the list_revisions tool.
func (s *Server) handleListRevisions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revisions, err := s.store.ListRevisions(ctx)

View File

@@ -7,7 +7,7 @@ import (
"io"
"log"
"git.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/database"
)
// ServerMode indicates which type of tools the server should expose.
@@ -45,7 +45,7 @@ type ServerConfig struct {
func DefaultNixOSConfig() ServerConfig {
return ServerConfig{
Name: "nixos-options",
Version: "0.3.0",
Version: "0.4.0",
DefaultChannel: "nixos-stable",
SourceName: "nixpkgs",
Mode: ModeOptions,
@@ -57,7 +57,9 @@ If the current project contains a flake.lock file, you can index the exact nixpk
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.`,
This ensures option documentation matches the nixpkgs version the project actually uses.
Note: index_revision also indexes packages when available, so both options and packages become searchable.`,
}
}
@@ -65,7 +67,7 @@ This ensures option documentation matches the nixpkgs version the project actual
func DefaultNixpkgsPackagesConfig() ServerConfig {
return ServerConfig{
Name: "nixpkgs-packages",
Version: "0.3.0",
Version: "0.4.0",
DefaultChannel: "nixos-stable",
SourceName: "nixpkgs",
Mode: ModePackages,
@@ -73,7 +75,9 @@ func DefaultNixpkgsPackagesConfig() ServerConfig {
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
1. Read the flake.lock file to find the nixpkgs "rev" field
2. Ensure the revision is indexed (packages are indexed separately from options)
2. Call index_revision with that git hash to index packages for that specific version
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures package information matches the nixpkgs version the project actually uses.`,
}
@@ -83,7 +87,7 @@ This ensures package information matches the nixpkgs version the project actuall
func DefaultMonitoringConfig() ServerConfig {
return ServerConfig{
Name: "lab-monitoring",
Version: "0.3.0",
Version: "0.3.1",
Mode: ModeCustom,
Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts.
@@ -427,7 +431,7 @@ func (s *Server) getOptionToolDefinitions() []Tool {
},
{
Name: "index_revision",
Description: fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo),
Description: s.indexRevisionDescription(sourceRepo),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
@@ -464,6 +468,15 @@ func (s *Server) getOptionToolDefinitions() []Tool {
}
}
// indexRevisionDescription returns the description for the index_revision tool,
// adjusted based on whether packages are also indexed.
func (s *Server) indexRevisionDescription(sourceRepo string) string {
if s.config.SourceName == "nixpkgs" {
return fmt.Sprintf("Index a %s revision to make its options and packages searchable", sourceRepo)
}
return fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo)
}
// getPackageToolDefinitions returns the tool definitions for packages mode.
func (s *Server) getPackageToolDefinitions() []Tool {
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
@@ -547,6 +560,20 @@ func (s *Server) getPackageToolDefinitions() []Tool {
Required: []string{"path"},
},
},
{
Name: "index_revision",
Description: "Index a nixpkgs revision to make its packages searchable",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"revision": {
Type: "string",
Description: fmt.Sprintf("Git hash (full or short) or channel name (e.g., %s)", exampleChannels),
},
},
Required: []string{"revision"},
},
},
{
Name: "list_revisions",
Description: "List all indexed nixpkgs revisions",

View File

@@ -8,8 +8,9 @@ import (
"strings"
"testing"
"git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/nixos"
"code.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/nixos"
"code.t-juice.club/torjus/labmcp/internal/packages"
)
func TestServerInitialize(t *testing.T) {
@@ -145,6 +146,110 @@ func TestServerNotification(t *testing.T) {
}
}
func TestPackagesServerToolsList(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil, DefaultNixpkgsPackagesConfig())
pkgIndexer := packages.NewIndexer(store)
server.RegisterPackageHandlers(pkgIndexer)
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
resp := runRequest(t, server, input)
if resp.Error != nil {
t.Fatalf("Unexpected error: %v", resp.Error)
}
result, ok := resp.Result.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", resp.Result)
}
tools, ok := result["tools"].([]interface{})
if !ok {
t.Fatalf("Expected tools array, got %T", result["tools"])
}
// Should have 6 tools (search_packages, get_package, get_file, index_revision, list_revisions, delete_revision)
if len(tools) != 6 {
t.Errorf("Expected 6 tools, got %d", len(tools))
}
expectedTools := map[string]bool{
"search_packages": false,
"get_package": false,
"get_file": false,
"index_revision": false,
"list_revisions": false,
"delete_revision": false,
}
for _, tool := range tools {
toolMap := tool.(map[string]interface{})
name := toolMap["name"].(string)
if _, ok := expectedTools[name]; ok {
expectedTools[name] = true
}
}
for name, found := range expectedTools {
if !found {
t.Errorf("Tool %q not found in tools list", name)
}
}
}
func TestOptionsServerWithPackagesToolsList(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil, DefaultNixOSConfig())
indexer := nixos.NewIndexer(store)
pkgIndexer := packages.NewIndexer(store)
server.RegisterHandlersWithPackages(indexer, pkgIndexer)
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
resp := runRequest(t, server, input)
if resp.Error != nil {
t.Fatalf("Unexpected error: %v", resp.Error)
}
result, ok := resp.Result.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", resp.Result)
}
tools, ok := result["tools"].([]interface{})
if !ok {
t.Fatalf("Expected tools array, got %T", result["tools"])
}
// Should still have 6 tools (same as options-only)
if len(tools) != 6 {
t.Errorf("Expected 6 tools, got %d", len(tools))
}
// Verify index_revision is present
found := false
for _, tool := range tools {
toolMap := tool.(map[string]interface{})
if toolMap["name"].(string) == "index_revision" {
found = true
// For nixpkgs source, description should mention packages
desc := toolMap["description"].(string)
if !strings.Contains(desc, "packages") {
t.Errorf("index_revision description should mention packages, got: %s", desc)
}
break
}
}
if !found {
t.Error("index_revision tool not found in tools list")
}
}
func TestGetFilePathValidation(t *testing.T) {
store := setupTestStore(t)
server := setupTestServer(t, store)

View File

@@ -7,7 +7,7 @@ import (
"strings"
"time"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"code.t-juice.club/torjus/labmcp/internal/mcp"
)
// AlertSummary queries Alertmanager for active (non-silenced) alerts and returns

View File

@@ -10,7 +10,7 @@ import (
"strings"
"testing"
"git.t-juice.club/torjus/labmcp/internal/mcp"
"code.t-juice.club/torjus/labmcp/internal/mcp"
)
// setupTestServer creates a test MCP server with monitoring handlers backed by test HTTP servers.
@@ -31,7 +31,7 @@ func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc, loki
var lokiSrv *httptest.Server
if len(lokiHandler) > 0 && lokiHandler[0] != nil {
lokiSrv = httptest.NewServer(lokiHandler[0])
loki = NewLokiClient(lokiSrv.URL)
loki = NewLokiClient(LokiClientOptions{BaseURL: lokiSrv.URL})
}
RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true})

View File

@@ -11,16 +11,27 @@ import (
"time"
)
// LokiClientOptions configures the Loki client.
type LokiClientOptions struct {
BaseURL string
Username string
Password string
}
// LokiClient is an HTTP client for the Loki API.
type LokiClient struct {
baseURL string
username string
password string
httpClient *http.Client
}
// NewLokiClient creates a new Loki API client.
func NewLokiClient(baseURL string) *LokiClient {
func NewLokiClient(opts LokiClientOptions) *LokiClient {
return &LokiClient{
baseURL: strings.TrimRight(baseURL, "/"),
baseURL: strings.TrimRight(opts.BaseURL, "/"),
username: opts.Username,
password: opts.Password,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
@@ -94,6 +105,10 @@ func (c *LokiClient) get(ctx context.Context, path string, params url.Values) (j
return nil, fmt.Errorf("failed to create request: %w", err)
}
if c.username != "" {
req.SetBasicAuth(c.username, c.password)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)

View File

@@ -42,7 +42,7 @@ func TestLokiClient_QueryRange(t *testing.T) {
}))
defer srv.Close()
client := NewLokiClient(srv.URL)
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
start := time.Unix(0, 1234567890000000000)
end := time.Unix(0, 1234567899000000000)
data, err := client.QueryRange(context.Background(), `{job="varlogs"}`, start, end, 10, "backward")
@@ -78,7 +78,7 @@ func TestLokiClient_QueryRangeError(t *testing.T) {
}))
defer srv.Close()
client := NewLokiClient(srv.URL)
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
_, err := client.QueryRange(context.Background(), "invalid{", time.Now().Add(-time.Hour), time.Now(), 100, "backward")
if err == nil {
t.Fatal("expected error, got nil")
@@ -102,7 +102,7 @@ func TestLokiClient_Labels(t *testing.T) {
}))
defer srv.Close()
client := NewLokiClient(srv.URL)
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
labels, err := client.Labels(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -130,7 +130,7 @@ func TestLokiClient_LabelValues(t *testing.T) {
}))
defer srv.Close()
client := NewLokiClient(srv.URL)
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
values, err := client.LabelValues(context.Background(), "job")
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -144,6 +144,65 @@ func TestLokiClient_LabelValues(t *testing.T) {
}
}
func TestLokiClient_BasicAuth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok {
t.Error("expected basic auth to be set")
}
if user != "myuser" {
t.Errorf("expected username=myuser, got %s", user)
}
if pass != "mypass" {
t.Errorf("expected password=mypass, got %s", pass)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"status": "success",
"data": ["job"]
}`))
}))
defer srv.Close()
client := NewLokiClient(LokiClientOptions{
BaseURL: srv.URL,
Username: "myuser",
Password: "mypass",
})
labels, err := client.Labels(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(labels) != 1 || labels[0] != "job" {
t.Errorf("unexpected labels: %v", labels)
}
}
func TestLokiClient_NoAuthWhenNoCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, _, ok := r.BasicAuth(); ok {
t.Error("expected no basic auth header, but it was set")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"status": "success",
"data": ["job"]
}`))
}))
defer srv.Close()
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
labels, err := client.Labels(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(labels) != 1 || labels[0] != "job" {
t.Errorf("unexpected labels: %v", labels)
}
}
func TestLokiClient_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@@ -151,7 +210,7 @@ func TestLokiClient_HTTPError(t *testing.T) {
}))
defer srv.Close()
client := NewLokiClient(srv.URL)
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
_, err := client.QueryRange(context.Background(), `{job="test"}`, time.Now().Add(-time.Hour), time.Now(), 100, "backward")
if err == nil {
t.Fatal("expected error, got nil")

View File

@@ -15,8 +15,8 @@ import (
"strings"
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/options"
"code.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/options"
)
// revisionPattern validates revision strings to prevent injection attacks.

View File

@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/database"
)
// TestNixpkgsRevision is the revision from flake.lock used for testing.

View File

@@ -4,7 +4,7 @@ package options
import (
"context"
"git.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/database"
)
// IndexResult contains the results of an indexing operation.

View File

@@ -12,7 +12,7 @@ import (
"strings"
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
"code.t-juice.club/torjus/labmcp/internal/database"
)
// revisionPattern validates revision strings to prevent injection attacks.

View File

@@ -39,6 +39,18 @@ in
description = "Loki base URL. When set, enables log query tools (query_logs, list_labels, list_label_values).";
};
lokiUsername = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Username for Loki basic authentication.";
};
lokiPasswordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to a file containing the password for Loki basic authentication. Recommended over storing secrets in the Nix store.";
};
enableSilences = lib.mkOption {
type = lib.types.bool;
default = false;
@@ -112,12 +124,17 @@ in
ALERTMANAGER_URL = cfg.alertmanagerUrl;
} // lib.optionalAttrs (cfg.lokiUrl != null) {
LOKI_URL = cfg.lokiUrl;
} // lib.optionalAttrs (cfg.lokiUsername != null) {
LOKI_USERNAME = cfg.lokiUsername;
};
script = let
httpFlags = mkHttpFlags cfg.http;
silenceFlag = lib.optionalString cfg.enableSilences "--enable-silences";
in ''
${lib.optionalString (cfg.lokiPasswordFile != null) ''
export LOKI_PASSWORD="$(< "$CREDENTIALS_DIRECTORY/loki-password")"
''}
exec ${cfg.package}/bin/lab-monitoring serve ${httpFlags} ${silenceFlag}
'';
@@ -126,7 +143,9 @@ in
DynamicUser = true;
Restart = "on-failure";
RestartSec = "5s";
} // lib.optionalAttrs (cfg.lokiPasswordFile != null) {
LoadCredential = [ "loki-password:${cfg.lokiPasswordFile}" ];
} // {
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";

View File

@@ -7,7 +7,7 @@
buildGoModule {
inherit pname src;
version = "0.3.0";
version = "0.4.0";
vendorHash = "sha256-XrTtiaQT5br+0ZXz8//rc04GZn/HlQk7l8Nx/+Uil/I=";
@@ -22,7 +22,7 @@ buildGoModule {
meta = with lib; {
inherit description mainProgram;
homepage = "https://git.t-juice.club/torjus/labmcp";
homepage = "https://code.t-juice.club/torjus/labmcp";
license = licenses.mit;
maintainers = [ ];
};