Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 75 additions & 43 deletions mcp/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"mcp-server/cmd/descriptions"
"mcp-server/cmd/loader"
"mcp-server/cmd/version"
"mcp-server/pkg/auth"
"mcp-server/pkg/helper"
"mcp-server/pkg/mcptypes"
"mcp-server/pkg/rules"
"net/http"
Expand All @@ -21,12 +23,8 @@ import (
"strings"
"time"

"mcp-server/pkg/auth"
"mcp-server/pkg/helper"

"github.com/netapp/harvest/v2/pkg/slogx"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/netapp/harvest/v2/pkg/slogx"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -167,12 +165,12 @@ func handleValidationError(message string) *mcp.CallToolResult {
}
}

func makePrometheusAPICall(endpoint string) ([]byte, error) {
fullURL := tsdbConfig.URL + endpoint
func makePrometheusAPICall(config auth.TSDBConfig, endpoint string) ([]byte, error) {
fullURL := config.URL + endpoint

logger.Debug("Making Prometheus API call", slog.String("url", fullURL))

resp, err := auth.MakeRequest(tsdbConfig, fullURL)
resp, err := auth.MakeRequest(config, fullURL)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
Expand All @@ -198,16 +196,46 @@ func formatDataResponse(data any) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{Content: content}, nil, nil
}

// resolveTSDBConfig returns the appropriate TSDBConfig to use for a request
// If override parameters are provided, creates a new config otherwise uses default
func resolveTSDBConfig(override *mcptypes.TSDBOverride) auth.TSDBConfig {
if override == nil || override.URL == "" {
return tsdbConfig
}

logger.Debug("using per-request TSDB URL override",
slog.String("url", override.URL),
slog.Bool("custom_auth", override.Username != ""))

config := auth.TSDBConfig{
URL: override.URL,
Timeout: tsdbConfig.Timeout,
RulesPath: tsdbConfig.RulesPath,
Auth: auth.Config{
Type: auth.None,
InsecureSkipTLS: tsdbConfig.Auth.InsecureSkipTLS,
},
}

if override.Username != "" && override.Password != "" {
config.Auth.Type = auth.Basic
config.Auth.Username = override.Username
config.Auth.Password = override.Password
}

return config
}

func addTool[T any](server *mcp.Server, name, description string, handler func(context.Context, *mcp.CallToolRequest, T) (*mcp.CallToolResult, any, error)) {
mcp.AddTool(server, &mcp.Tool{
Name: name,
Description: description,
}, handler)
}

func executeTSDBQuery(queryURL string, params url.Values) (*mcptypes.MetricsResponse, error) {
func executeTSDBQuery(config auth.TSDBConfig, queryURL string, params url.Values) (*mcptypes.MetricsResponse, error) {
fullURL := fmt.Sprintf("%s?%s", queryURL, params.Encode())
resp, err := auth.MakeRequest(tsdbConfig, fullURL)
resp, err := auth.MakeRequest(config, fullURL)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
Expand Down Expand Up @@ -245,20 +273,22 @@ func formatJSONResponse(data any) ([]mcp.Content, error) {
}

// MetricsQuery executes a time series database instant query
func MetricsQuery(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.QueryArgs) (*mcp.CallToolResult, any, error) {
func MetricsQuery(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.QueryRequest) (*mcp.CallToolResult, any, error) {
if err := helper.ValidateQueryArgs(args.Query); err != nil {
return handleValidationError(err.Error()), nil, err
}

queryURL := tsdbConfig.URL + "/api/v1/query"
config := resolveTSDBConfig(args.TSDBOverride)

queryURL := config.URL + "/api/v1/query"
urlValues := url.Values{}
urlValues.Set("query", args.Query)

logger.Debug("Executing Prometheus instant query",
slog.String("query", args.Query),
slog.String("url", queryURL))

promResp, err := executeTSDBQuery(queryURL, urlValues)
promResp, err := executeTSDBQuery(config, queryURL, urlValues)
if err != nil {
logger.Error("Prometheus query failed", slogx.Err(err), slog.String("query", helper.TruncateString(args.Query, 100)))
return handlePrometheusError(err, "Prometheus query"), nil, nil
Expand All @@ -268,12 +298,14 @@ func MetricsQuery(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.Query
}

// MetricsRangeQuery executes a time series database range query
func MetricsRangeQuery(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.RangeQueryArgs) (*mcp.CallToolResult, any, error) {
func MetricsRangeQuery(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.RangeQueryRequest) (*mcp.CallToolResult, any, error) {
if err := helper.ValidateRangeQueryArgs(args.Query, args.Start, args.End, args.Step); err != nil {
return handleValidationError(err.Error()), nil, err
}

queryURL := tsdbConfig.URL + "/api/v1/query_range"
config := resolveTSDBConfig(args.TSDBOverride)

queryURL := config.URL + "/api/v1/query_range"
urlValues := url.Values{}
urlValues.Set("query", args.Query)
urlValues.Set("start", args.Start)
Expand All @@ -287,7 +319,7 @@ func MetricsRangeQuery(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.
slog.String("step", args.Step),
slog.String("url", queryURL))

promResp, err := executeTSDBQuery(queryURL, urlValues)
promResp, err := executeTSDBQuery(config, queryURL, urlValues)
if err != nil {
logger.Error("Prometheus range query failed", slogx.Err(err), slog.String("query", helper.TruncateString(args.Query, 100)))
return handlePrometheusError(err, "Prometheus range query"), nil, nil
Expand Down Expand Up @@ -325,24 +357,24 @@ func filterStrings(items []string, pattern string) []string {
}

// makePrometheusAPICallWithMatches performs API call with optional label matchers
func makePrometheusAPICallWithMatches(endpoint string, matches []string) ([]byte, error) {
func makePrometheusAPICallWithMatches(config auth.TSDBConfig, endpoint string, matches []string) ([]byte, error) {
var fullURL string
if len(matches) > 0 {
// Build URL with matches parameter
params := url.Values{}
for _, match := range matches {
params.Add("match[]", match)
}
fullURL = tsdbConfig.URL + endpoint + "?" + params.Encode()
fullURL = config.URL + endpoint + "?" + params.Encode()
} else {
fullURL = tsdbConfig.URL + endpoint
fullURL = config.URL + endpoint
}

logger.Debug("Making Prometheus API call with matches",
slog.String("url", fullURL),
slog.Any("matches", matches))

resp, err := auth.MakeRequest(tsdbConfig, fullURL)
resp, err := auth.MakeRequest(config, fullURL)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
Expand All @@ -354,15 +386,17 @@ func makePrometheusAPICallWithMatches(endpoint string, matches []string) ([]byte
}

// ListMetrics lists available metrics from time series database
func ListMetrics(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.ListMetricsArgs) (*mcp.CallToolResult, any, error) {
func ListMetrics(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.ListMetricsRequest) (*mcp.CallToolResult, any, error) {
var body []byte
var err error

config := resolveTSDBConfig(args.TSDBOverride)

if len(args.Matches) > 0 {
logger.Debug("Using server-side filtering with matches", slog.Any("matches", args.Matches))
body, err = makePrometheusAPICallWithMatches("/api/v1/label/__name__/values", args.Matches)
body, err = makePrometheusAPICallWithMatches(config, "/api/v1/label/__name__/values", args.Matches)
} else {
body, err = makePrometheusAPICall("/api/v1/label/__name__/values")
body, err = makePrometheusAPICall(config, "/api/v1/label/__name__/values")
}

if err != nil {
Expand Down Expand Up @@ -416,12 +450,14 @@ func ListMetrics(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.ListMe
}

// ListLabelValues lists available values for a specific label from Prometheus
func ListLabelValues(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.ListLabelValuesArgs) (*mcp.CallToolResult, any, error) {
func ListLabelValues(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.ListLabelValuesRequest) (*mcp.CallToolResult, any, error) {
if args.Label == "" {
return handleValidationError("label parameter is required"), nil, errors.New("label parameter is required")
}

body, err := makePrometheusAPICall("/api/v1/label/" + args.Label + "/values")
config := resolveTSDBConfig(args.TSDBOverride)

body, err := makePrometheusAPICall(config, "/api/v1/label/"+args.Label+"/values")
if err != nil {
logger.Error("Failed to query Prometheus label values", slogx.Err(err), slog.String("label", args.Label))
return handlePrometheusError(err, fmt.Sprintf("query label values for '%s'", args.Label)), nil, nil
Expand Down Expand Up @@ -454,8 +490,9 @@ func ListLabelValues(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.Li
}

// ListAllLabelNames lists all available label names (dimensions) from Prometheus
func ListAllLabelNames(_ context.Context, _ *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
body, err := makePrometheusAPICall("/api/v1/labels")
func ListAllLabelNames(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.ListAllLabelNamesRequest) (*mcp.CallToolResult, any, error) {
config := resolveTSDBConfig(args.TSDBOverride)
body, err := makePrometheusAPICall(config, "/api/v1/labels")
if err != nil {
return handlePrometheusError(err, "query Prometheus label names"), nil, nil
}
Expand All @@ -480,10 +517,11 @@ func ListAllLabelNames(_ context.Context, _ *mcp.CallToolRequest, _ any) (*mcp.C
return formatDataResponse(response)
}

func GetActiveAlerts(_ context.Context, _ *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
queryURL := tsdbConfig.URL + "/api/v1/alerts"
func GetActiveAlerts(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.GetActiveAlertsRequest) (*mcp.CallToolResult, any, error) {
config := resolveTSDBConfig(args.TSDBOverride)
queryURL := config.URL + "/api/v1/alerts"

resp, err := auth.MakeRequest(tsdbConfig, queryURL)
resp, err := auth.MakeRequest(config, queryURL)
if err != nil {
logger.Error("Failed to query Prometheus alerts", slogx.Err(err))
return &mcp.CallToolResult{
Expand Down Expand Up @@ -578,7 +616,7 @@ func countAlertsBySeverity(alerts []any) (int, int, int) {
return critical, warning, info
}

func InfrastructureHealth(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.InfrastructureHealthArgs) (*mcp.CallToolResult, any, error) {
func InfrastructureHealth(_ context.Context, _ *mcp.CallToolRequest, args mcptypes.InfrastructureHealthRequest) (*mcp.CallToolResult, any, error) {
healthReport := strings.Builder{}
var report string
healthReport.WriteString("## ONTAP Infrastructure Health Report\n\n")
Expand All @@ -601,8 +639,10 @@ func InfrastructureHealth(_ context.Context, _ *mcp.CallToolRequest, args mcptyp
{"Health Alerts", "{__name__=~\"health_.*\"}", "Active health alerts", true},
}

config := resolveTSDBConfig(args.TSDBOverride)

for _, check := range healthChecks {
queryURL := tsdbConfig.URL + "/api/v1/query"
queryURL := config.URL + "/api/v1/query"
urlValues := url.Values{}
urlValues.Set("query", check.query)

Expand All @@ -612,7 +652,7 @@ func InfrastructureHealth(_ context.Context, _ *mcp.CallToolRequest, args mcptyp
slog.String("query", check.query),
slog.String("url", queryURL))

promResp, err := executeTSDBQuery(queryURL, urlValues)
promResp, err := executeTSDBQuery(config, queryURL, urlValues)
if err != nil {
healthReport.WriteString(fmt.Sprintf("❌ **%s**: Error querying - %v\n", check.name, err))
continue
Expand Down Expand Up @@ -941,15 +981,7 @@ func runHTTPServer(server *mcp.Server) {
logger.Info("mcp server shutdown gracefully")
}

type GetMetricDescriptionRequest struct {
MetricName string `json:"metricName"`
}

type SearchMetricsRequest struct {
Pattern string `json:"pattern"`
}

func GetMetricDescription(_ context.Context, _ *mcp.CallToolRequest, params GetMetricDescriptionRequest) (*mcp.CallToolResult, any, error) {
func GetMetricDescription(_ context.Context, _ *mcp.CallToolRequest, params mcptypes.GetMetricDescriptionRequest) (*mcp.CallToolResult, any, error) {
if len(metricDescriptions) == 0 {
return &mcp.CallToolResult{
Content: []mcp.Content{
Expand Down Expand Up @@ -986,7 +1018,7 @@ func GetMetricDescription(_ context.Context, _ *mcp.CallToolRequest, params GetM
}, nil, nil
}

func SearchMetrics(_ context.Context, _ *mcp.CallToolRequest, params SearchMetricsRequest) (*mcp.CallToolResult, any, error) {
func SearchMetrics(_ context.Context, _ *mcp.CallToolRequest, params mcptypes.SearchMetricsRequest) (*mcp.CallToolResult, any, error) {
if len(metricDescriptions) == 0 {
return &mcp.CallToolResult{
Content: []mcp.Content{
Expand Down
61 changes: 46 additions & 15 deletions mcp/pkg/mcptypes/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,58 @@ type LabelsResponse struct {
ErrorType string `json:"errorType,omitempty"`
}

type QueryArgs struct {
Query string `json:"query" jsonschema:"PromQL query string"`
// TSDBOverride allows per-request override of the Prometheus/VictoriaMetrics URL and credentials
// This is useful when a single MCP server needs to query multiple TSDB instances
type TSDBOverride struct {
URL string `json:"tsdb_url,omitempty" jsonschema:"Optional override for Prometheus/VictoriaMetrics URL. If not provided, uses the default HARVEST_TSDB_URL from server configuration. Example: http://prometheus-prod:9090"`
Username string `json:"tsdb_username,omitempty" jsonschema:"Optional basic auth username. Only used if tsdb_url is provided. Overrides default authentication."`
Password string `json:"tsdb_password,omitempty" jsonschema:"Optional basic auth password. Only used if tsdb_url is provided. Overrides default authentication."`
}

type RangeQueryArgs struct {
Query string `json:"query" jsonschema:"PromQL query string"`
Start string `json:"start" jsonschema:"Start timestamp (RFC3339 or Unix timestamp)"`
End string `json:"end" jsonschema:"End timestamp (RFC3339 or Unix timestamp)"`
Step string `json:"step" jsonschema:"Query resolution step width (e.g., '15s', '1m', '1h')"`
type QueryRequest struct {
Query string `json:"query" jsonschema:"PromQL query string"`
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}

type ListMetricsArgs struct {
Match string `json:"match,omitempty" jsonschema:"Optional metric name pattern to filter results. Supports: 1) Simple string matching (e.g., 'volume'), 2) Regex patterns (e.g., '.*volume.*space.*'), 3) PromQL label matchers (e.g., '{__name__=~\".*volume.*\"}')"`
Matches []string `json:"matches,omitempty" jsonschema:"Array of PromQL label matchers for server-side filtering (e.g., ['{__name__=~\".*volume.*space.*\"}']). More efficient than 'match' for complex patterns."`
type RangeQueryRequest struct {
Query string `json:"query" jsonschema:"PromQL query string"`
Start string `json:"start" jsonschema:"Start timestamp (RFC3339 or Unix timestamp)"`
End string `json:"end" jsonschema:"End timestamp (RFC3339 or Unix timestamp)"`
Step string `json:"step" jsonschema:"Query resolution step width (e.g., '15s', '1m', '1h')"`
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}

type InfrastructureHealthArgs struct {
IncludeDetails bool `json:"includeDetails,omitempty" jsonschema:"Include detailed metrics in the response"`
type ListMetricsRequest struct {
Match string `json:"match,omitempty" jsonschema:"Optional metric name pattern to filter results. Supports: 1) Simple string matching (e.g., 'volume'), 2) Regex patterns (e.g., '.*volume.*space.*'), 3) PromQL label matchers (e.g., '{__name__=~\".*volume.*\"}')"`
Matches []string `json:"matches,omitempty" jsonschema:"Array of PromQL label matchers for server-side filtering (e.g., ['{__name__=~\".*volume.*space.*\"}']). More efficient than 'match' for complex patterns."`
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}

type ListLabelValuesArgs struct {
Label string `json:"label" jsonschema:"Label name to get values for (e.g., 'cluster', 'node', 'volume')"`
Match string `json:"match,omitempty" jsonschema:"Optional pattern to filter label values. Supports simple string matching or regex patterns (e.g., '.*prod.*', '^cluster_[0-9]+$')"`
type InfrastructureHealthRequest struct {
IncludeDetails bool `json:"includeDetails,omitempty" jsonschema:"Include detailed metrics in the response"`
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}

type ListLabelValuesRequest struct {
Label string `json:"label" jsonschema:"Label name to get values for (e.g., 'cluster', 'node', 'volume')"`
Match string `json:"match,omitempty" jsonschema:"Optional pattern to filter label values. Supports simple string matching or regex patterns (e.g., '.*prod.*', '^cluster_[0-9]+$')"`
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}

type GetMetricDescriptionRequest struct {
MetricName string `json:"metricName" jsonschema:"The name of the metric to get description for"`
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}

type SearchMetricsRequest struct {
Pattern string `json:"pattern" jsonschema:"Search pattern to match against metric names and descriptions"`
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}

type GetActiveAlertsRequest struct {
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}

type ListAllLabelNamesRequest struct {
TSDBOverride *TSDBOverride `json:"tsdb_override,omitempty" jsonschema:"Optional override for TSDB connection"`
}
Loading