This repository has been archived on 2026-03-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
labmcp/internal/mcp/server.go
Torjus Håkestad 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

647 lines
19 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"git.t-juice.club/torjus/labmcp/internal/database"
)
// ServerMode indicates which type of tools the server should expose.
type ServerMode string
const (
// ModeOptions exposes only option-related tools.
ModeOptions ServerMode = "options"
// ModePackages exposes only package-related tools.
ModePackages ServerMode = "packages"
// ModeCustom exposes externally registered tools (no database required).
ModeCustom ServerMode = "custom"
)
// 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
// InstructionsFunc, if set, is called during initialization to generate
// dynamic instructions. Its return value is appended to Instructions.
InstructionsFunc func() 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
// Mode specifies which tools to expose (options or packages).
Mode ServerMode
}
// DefaultNixOSConfig returns the default configuration for NixOS options server.
func DefaultNixOSConfig() ServerConfig {
return ServerConfig{
Name: "nixos-options",
Version: "0.4.0",
DefaultChannel: "nixos-stable",
SourceName: "nixpkgs",
Mode: ModeOptions,
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.
Note: index_revision also indexes packages when available, so both options and packages become searchable.`,
}
}
// DefaultNixpkgsPackagesConfig returns the default configuration for nixpkgs packages server.
func DefaultNixpkgsPackagesConfig() ServerConfig {
return ServerConfig{
Name: "nixpkgs-packages",
Version: "0.4.0",
DefaultChannel: "nixos-stable",
SourceName: "nixpkgs",
Mode: ModePackages,
Instructions: `Nixpkgs Packages MCP Server - Search and query Nix packages from nixpkgs.
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
1. Read the flake.lock file to find the nixpkgs "rev" field
2. 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.`,
}
}
// DefaultMonitoringConfig returns the default configuration for the lab monitoring server.
func DefaultMonitoringConfig() ServerConfig {
return ServerConfig{
Name: "lab-monitoring",
Version: "0.3.1",
Mode: ModeCustom,
Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts.
Tools for querying your monitoring stack:
- Search and query Prometheus metrics with PromQL
- List and inspect alerts from Alertmanager
- View scrape target health status
- Manage alert silences
- Query logs via LogQL (when Loki is configured)
All queries are executed against live Prometheus, Alertmanager, and Loki HTTP APIs.`,
}
}
// DefaultHomeManagerConfig returns the default configuration for Home Manager options server.
func DefaultHomeManagerConfig() ServerConfig {
return ServerConfig{
Name: "hm-options",
Version: "0.3.0",
DefaultChannel: "hm-stable",
SourceName: "home-manager",
Mode: ModeOptions,
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.`,
}
}
// DefaultGitExplorerConfig returns the default configuration for the git-explorer server.
func DefaultGitExplorerConfig() ServerConfig {
return ServerConfig{
Name: "git-explorer",
Version: "0.1.0",
Mode: ModeCustom,
Instructions: `Git Explorer MCP Server - Read-only access to git repository information.
Tools for exploring git repositories:
- Resolve refs (branches, tags, commits) to commit hashes
- View commit logs with filtering by author, path, or range
- Get full commit details including file change statistics
- Compare commits to see which files changed
- Read file contents at any commit
- Check ancestry relationships between commits
- Search commit messages
All operations are read-only and will never modify the repository.`,
}
}
// Server is an MCP server that handles JSON-RPC requests.
type Server struct {
store database.Store
config ServerConfig
tools map[string]ToolHandler
toolDefs []Tool
initialized bool
logger *log.Logger
}
// 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 with a database store.
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,
}
s.registerTools()
return s
}
// NewGenericServer creates a new MCP server without a database store.
// Use RegisterTool to add tools externally.
func NewGenericServer(logger *log.Logger, config ServerConfig) *Server {
if logger == nil {
logger = log.New(io.Discard, "", 0)
}
return &Server{
config: config,
tools: make(map[string]ToolHandler),
logger: logger,
}
}
// RegisterTool registers an externally defined tool with its handler.
func (s *Server) RegisterTool(tool Tool, handler ToolHandler) {
s.toolDefs = append(s.toolDefs, tool)
s.tools[tool.Name] = handler
}
// registerTools registers all available tools.
func (s *Server) registerTools() {
// Tools will be implemented in handlers.go
}
// Run starts the server using STDIO transport (backward compatibility).
func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error {
transport := NewStdioTransport(s, r, w)
return transport.Run(ctx)
}
// HandleMessage parses a JSON-RPC message and returns the response.
// Returns (nil, nil) for notifications that don't require a response.
func (s *Server) HandleMessage(ctx context.Context, data []byte) (*Response, error) {
var req Request
if err := json.Unmarshal(data, &req); err != nil {
return &Response{
JSONRPC: "2.0",
Error: &Error{
Code: ParseError,
Message: "Parse error",
Data: err.Error(),
},
}, nil
}
return s.HandleRequest(ctx, &req), nil
}
// HandleRequest processes a single request and returns a response.
// Returns nil for notifications that don't require a response.
func (s *Server) HandleRequest(ctx context.Context, req *Request) *Response {
return s.handleRequest(ctx, req)
}
// handleRequest processes a single request and returns a response.
func (s *Server) handleRequest(ctx context.Context, req *Request) *Response {
s.logger.Printf("Received request: method=%s id=%v", req.Method, req.ID)
switch req.Method {
case MethodInitialize:
return s.handleInitialize(req)
case MethodInitialized:
// This is a notification, no response needed
s.initialized = true
return nil
case MethodToolsList:
return s.handleToolsList(req)
case MethodToolsCall:
return s.handleToolsCall(ctx, req)
default:
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Error: &Error{
Code: MethodNotFound,
Message: "Method not found",
Data: req.Method,
},
}
}
}
// handleInitialize processes the initialize request.
func (s *Server) handleInitialize(req *Request) *Response {
var params InitializeParams
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Error: &Error{
Code: InvalidParams,
Message: "Invalid params",
Data: err.Error(),
},
}
}
}
s.logger.Printf("Client: %s %s, protocol: %s",
params.ClientInfo.Name, params.ClientInfo.Version, params.ProtocolVersion)
instructions := s.config.Instructions
if s.config.InstructionsFunc != nil {
if extra := s.config.InstructionsFunc(); extra != "" {
instructions += "\n\n" + extra
}
}
result := InitializeResult{
ProtocolVersion: ProtocolVersion,
Capabilities: Capabilities{
Tools: &ToolsCapability{
ListChanged: false,
},
},
ServerInfo: Implementation{
Name: s.config.Name,
Version: s.config.Version,
},
Instructions: instructions,
}
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
}
// handleToolsList returns the list of available tools.
func (s *Server) handleToolsList(req *Request) *Response {
tools := s.getToolDefinitions()
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: ListToolsResult{Tools: tools},
}
}
// getToolDefinitions returns the tool definitions.
func (s *Server) getToolDefinitions() []Tool {
// For custom mode, return externally registered tools
if s.config.Mode == ModeCustom {
return s.toolDefs
}
// For packages mode, return package tools
if s.config.Mode == ModePackages {
return s.getPackageToolDefinitions()
}
// Default: options mode
return s.getOptionToolDefinitions()
}
// getOptionToolDefinitions returns the tool definitions for options mode.
func (s *Server) getOptionToolDefinitions() []Tool {
// Determine naming based on source
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: fmt.Sprintf("Search for %s configuration options by name or description", optionType),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"query": {
Type: "string",
Description: "Search query (matches option names and descriptions)",
},
"revision": {
Type: "string",
Description: fmt.Sprintf("Git hash or channel name (e.g., %s). Uses default if not specified.", exampleChannels),
},
"type": {
Type: "string",
Description: "Filter by option type (e.g., 'boolean', 'string', 'list')",
},
"namespace": {
Type: "string",
Description: fmt.Sprintf("Filter by namespace prefix (e.g., '%s')", exampleNamespace),
},
"limit": {
Type: "integer",
Description: "Maximum number of results (default: 50)",
Default: 50,
},
},
Required: []string{"query"},
},
},
{
Name: "get_option",
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: fmt.Sprintf("Full option path (e.g., '%s')", exampleOption),
},
"revision": {
Type: "string",
Description: "Git hash or channel name. Uses default if not specified.",
},
"include_children": {
Type: "boolean",
Description: "Include direct children of this option (default: true)",
Default: true,
},
},
Required: []string{"name"},
},
},
{
Name: "get_file",
Description: fmt.Sprintf("Fetch the contents of a file from %s", sourceRepo),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"path": {
Type: "string",
Description: fmt.Sprintf("File path relative to %s root (e.g., '%s')", sourceRepo, exampleFilePath),
},
"revision": {
Type: "string",
Description: "Git hash or channel name. Uses default if not specified.",
},
"offset": {
Type: "integer",
Description: "Line offset (0-based). Default: 0",
Default: 0,
},
"limit": {
Type: "integer",
Description: "Maximum lines to return. Default: 250, use 0 for all lines",
Default: 250,
},
},
Required: []string{"path"},
},
},
{
Name: "index_revision",
Description: s.indexRevisionDescription(sourceRepo),
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: fmt.Sprintf("List all indexed %s revisions", sourceRepo),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{},
},
},
{
Name: "delete_revision",
Description: "Delete an indexed revision and all its data",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"revision": {
Type: "string",
Description: "Git hash or channel name of the revision to delete",
},
},
Required: []string{"revision"},
},
},
}
}
// 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'"
exampleFilePath := "pkgs/applications/networking/browsers/firefox/default.nix"
return []Tool{
{
Name: "search_packages",
Description: "Search for Nix packages by name or description",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"query": {
Type: "string",
Description: "Search query (matches package name, attr path, and description)",
},
"revision": {
Type: "string",
Description: fmt.Sprintf("Git hash or channel name (e.g., %s). Uses default if not specified.", exampleChannels),
},
"broken": {
Type: "boolean",
Description: "Filter by broken status (true = only broken, false = only working)",
},
"unfree": {
Type: "boolean",
Description: "Filter by license (true = only unfree, false = only free)",
},
"limit": {
Type: "integer",
Description: "Maximum number of results (default: 50)",
Default: 50,
},
},
Required: []string{"query"},
},
},
{
Name: "get_package",
Description: "Get full details for a specific Nix package by attribute path",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"attr_path": {
Type: "string",
Description: "Package attribute path (e.g., 'firefox', 'python312Packages.requests')",
},
"revision": {
Type: "string",
Description: "Git hash or channel name. Uses default if not specified.",
},
},
Required: []string{"attr_path"},
},
},
{
Name: "get_file",
Description: "Fetch the contents of a file from nixpkgs",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"path": {
Type: "string",
Description: fmt.Sprintf("File path relative to nixpkgs root (e.g., '%s')", exampleFilePath),
},
"revision": {
Type: "string",
Description: "Git hash or channel name. Uses default if not specified.",
},
"offset": {
Type: "integer",
Description: "Line offset (0-based). Default: 0",
Default: 0,
},
"limit": {
Type: "integer",
Description: "Maximum lines to return. Default: 250, use 0 for all lines",
Default: 250,
},
},
Required: []string{"path"},
},
},
{
Name: "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",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{},
},
},
{
Name: "delete_revision",
Description: "Delete an indexed revision and all its data",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"revision": {
Type: "string",
Description: "Git hash or channel name of the revision to delete",
},
},
Required: []string{"revision"},
},
},
}
}
// handleToolsCall handles a tool invocation.
func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
var params CallToolParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Error: &Error{
Code: InvalidParams,
Message: "Invalid params",
Data: err.Error(),
},
}
}
s.logger.Printf("Tool call: %s with args %v", params.Name, params.Arguments)
handler, ok := s.tools[params.Name]
if !ok {
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: CallToolResult{
Content: []Content{TextContent(fmt.Sprintf("Unknown tool: %s", params.Name))},
IsError: true,
},
}
}
result, err := handler(ctx, params.Arguments)
if err != nil {
s.logger.Printf("Tool error: %v", err)
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: ErrorContent(err),
}
}
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
}