New MCP server that queries live Prometheus and Alertmanager HTTP APIs with 8 tools: list_alerts, get_alert, search_metrics, get_metric_metadata, query (PromQL), list_targets, list_silences, and create_silence. Extends the MCP core with ModeCustom and NewGenericServer for servers that don't require a database. Includes CLI with direct commands (alerts, query, targets, metrics), NixOS module, and comprehensive httptest-based tests. Bumps existing binaries to 0.2.1 due to shared internal/mcp change. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
588 lines
17 KiB
Go
588 lines
17 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
|
|
// 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.2.1",
|
|
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.`,
|
|
}
|
|
}
|
|
|
|
// DefaultNixpkgsPackagesConfig returns the default configuration for nixpkgs packages server.
|
|
func DefaultNixpkgsPackagesConfig() ServerConfig {
|
|
return ServerConfig{
|
|
Name: "nixpkgs-packages",
|
|
Version: "0.2.1",
|
|
DefaultChannel: "nixos-stable",
|
|
SourceName: "nixpkgs",
|
|
Mode: ModePackages,
|
|
Instructions: `Nixpkgs Packages MCP Server - Search and query Nix packages from nixpkgs.
|
|
|
|
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
|
|
1. Read the flake.lock file to find the nixpkgs "rev" field
|
|
2. Ensure the revision is indexed (packages are indexed separately from options)
|
|
|
|
This ensures package information matches the nixpkgs version the project actually uses.`,
|
|
}
|
|
}
|
|
|
|
// DefaultMonitoringConfig returns the default configuration for the lab monitoring server.
|
|
func DefaultMonitoringConfig() ServerConfig {
|
|
return ServerConfig{
|
|
Name: "lab-monitoring",
|
|
Version: "0.1.0",
|
|
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
|
|
|
|
All queries are executed against live Prometheus and Alertmanager HTTP APIs.`,
|
|
}
|
|
}
|
|
|
|
// DefaultHomeManagerConfig returns the default configuration for Home Manager options server.
|
|
func DefaultHomeManagerConfig() ServerConfig {
|
|
return ServerConfig{
|
|
Name: "hm-options",
|
|
Version: "0.2.1",
|
|
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.`,
|
|
}
|
|
}
|
|
|
|
// 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, ¶ms); 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)
|
|
|
|
result := InitializeResult{
|
|
ProtocolVersion: ProtocolVersion,
|
|
Capabilities: Capabilities{
|
|
Tools: &ToolsCapability{
|
|
ListChanged: false,
|
|
},
|
|
},
|
|
ServerInfo: Implementation{
|
|
Name: s.config.Name,
|
|
Version: s.config.Version,
|
|
},
|
|
Instructions: s.config.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: fmt.Sprintf("Index a %s revision to make its options searchable", 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"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// getPackageToolDefinitions returns the tool definitions for packages mode.
|
|
func (s *Server) getPackageToolDefinitions() []Tool {
|
|
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
|
|
exampleFilePath := "pkgs/applications/networking/browsers/firefox/default.nix"
|
|
|
|
return []Tool{
|
|
{
|
|
Name: "search_packages",
|
|
Description: "Search for Nix packages by name or description",
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]Property{
|
|
"query": {
|
|
Type: "string",
|
|
Description: "Search query (matches package name, attr path, and description)",
|
|
},
|
|
"revision": {
|
|
Type: "string",
|
|
Description: fmt.Sprintf("Git hash or channel name (e.g., %s). Uses default if not specified.", exampleChannels),
|
|
},
|
|
"broken": {
|
|
Type: "boolean",
|
|
Description: "Filter by broken status (true = only broken, false = only working)",
|
|
},
|
|
"unfree": {
|
|
Type: "boolean",
|
|
Description: "Filter by license (true = only unfree, false = only free)",
|
|
},
|
|
"limit": {
|
|
Type: "integer",
|
|
Description: "Maximum number of results (default: 50)",
|
|
Default: 50,
|
|
},
|
|
},
|
|
Required: []string{"query"},
|
|
},
|
|
},
|
|
{
|
|
Name: "get_package",
|
|
Description: "Get full details for a specific Nix package by attribute path",
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]Property{
|
|
"attr_path": {
|
|
Type: "string",
|
|
Description: "Package attribute path (e.g., 'firefox', 'python312Packages.requests')",
|
|
},
|
|
"revision": {
|
|
Type: "string",
|
|
Description: "Git hash or channel name. Uses default if not specified.",
|
|
},
|
|
},
|
|
Required: []string{"attr_path"},
|
|
},
|
|
},
|
|
{
|
|
Name: "get_file",
|
|
Description: "Fetch the contents of a file from nixpkgs",
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]Property{
|
|
"path": {
|
|
Type: "string",
|
|
Description: fmt.Sprintf("File path relative to nixpkgs root (e.g., '%s')", exampleFilePath),
|
|
},
|
|
"revision": {
|
|
Type: "string",
|
|
Description: "Git hash or channel name. Uses default if not specified.",
|
|
},
|
|
"offset": {
|
|
Type: "integer",
|
|
Description: "Line offset (0-based). Default: 0",
|
|
Default: 0,
|
|
},
|
|
"limit": {
|
|
Type: "integer",
|
|
Description: "Maximum lines to return. Default: 250, use 0 for all lines",
|
|
Default: 250,
|
|
},
|
|
},
|
|
Required: []string{"path"},
|
|
},
|
|
},
|
|
{
|
|
Name: "list_revisions",
|
|
Description: "List all indexed nixpkgs revisions",
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]Property{},
|
|
},
|
|
},
|
|
{
|
|
Name: "delete_revision",
|
|
Description: "Delete an indexed revision and all its data",
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]Property{
|
|
"revision": {
|
|
Type: "string",
|
|
Description: "Git hash or channel name of the revision to delete",
|
|
},
|
|
},
|
|
Required: []string{"revision"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// handleToolsCall handles a tool invocation.
|
|
func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
|
|
var params CallToolParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); 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,
|
|
}
|
|
}
|