Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2026 The Jaeger Authors.
// SPDX-License-Identifier: Apache-2.0

package handlers

import (
"context"
"fmt"
"regexp"
"sort"

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/jaegertracing/jaeger/cmd/jaeger/internal/extension/jaegermcp/internal/types"
"github.com/jaegertracing/jaeger/cmd/jaeger/internal/extension/jaegerquery/querysvc"
)

const defaultServiceLimit = 100

// queryServiceGetServicesInterface defines the interface we need from QueryService for testing
type queryServiceGetServicesInterface interface {
GetServices(ctx context.Context) ([]string, error)
}

// getServicesHandler implements the get_services MCP tool.
// This tool lists available service names, optionally filtered by a regex pattern.
type getServicesHandler struct {
queryService queryServiceGetServicesInterface
}

// NewGetServicesHandler creates a new get_services handler and returns the handler function.
func NewGetServicesHandler(
queryService *querysvc.QueryService,
) mcp.ToolHandlerFor[types.GetServicesInput, types.GetServicesOutput] {
h := &getServicesHandler{
queryService: queryService,
}
return h.handle
}

// handle processes the get_services tool request.
func (h *getServicesHandler) handle(
ctx context.Context,
_ *mcp.CallToolRequest,
input types.GetServicesInput,
) (*mcp.CallToolResult, types.GetServicesOutput, error) {
// Get all services from storage
services, err := h.queryService.GetServices(ctx)
if err != nil {
return nil, types.GetServicesOutput{}, fmt.Errorf("failed to get services: %w", err)
}

// Apply pattern filter if provided
if input.Pattern != "" {
re, err := regexp.Compile(input.Pattern)
if err != nil {
return nil, types.GetServicesOutput{}, fmt.Errorf("invalid pattern: %w", err)
}

filtered := make([]string, 0, len(services))
for _, service := range services {
if re.MatchString(service) {
filtered = append(filtered, service)
}
}
services = filtered
}

// Sort services for consistent ordering
sort.Strings(services)

// Apply limit
limit := input.Limit
if limit <= 0 {
limit = defaultServiceLimit
}
if len(services) > limit {
services = services[:limit]
}

return nil, types.GetServicesOutput{
Services: services,
}, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
// Copyright (c) 2026 The Jaeger Authors.
// SPDX-License-Identifier: Apache-2.0

package handlers

import (
"context"
"errors"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/jaegertracing/jaeger/cmd/jaeger/internal/extension/jaegermcp/internal/types"
)

// mockGetServicesQueryService mocks the GetServices method for testing
type mockGetServicesQueryService struct {
getServicesFunc func(ctx context.Context) ([]string, error)
}

func (m *mockGetServicesQueryService) GetServices(ctx context.Context) ([]string, error) {
if m.getServicesFunc != nil {
return m.getServicesFunc(ctx)
}
return nil, nil
}

func TestGetServicesHandler_Success_AllServices(t *testing.T) {
testServices := []string{
"frontend",
"payment-service",
"cart-service",
"user-service",
}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

input := types.GetServicesInput{}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
assert.Equal(t, testServices, output.Services)
}

func TestGetServicesHandler_Success_WithPattern(t *testing.T) {
testServices := []string{
"frontend",
"payment-service",
"payment-gateway",
"cart-service",
"user-service",
}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

// Filter for services containing "payment"
input := types.GetServicesInput{
Pattern: "payment",
}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
require.Len(t, output.Services, 2)
assert.Contains(t, output.Services, "payment-service")
assert.Contains(t, output.Services, "payment-gateway")
}

func TestGetServicesHandler_Success_WithRegexPattern(t *testing.T) {
testServices := []string{
"frontend-prod",
"frontend-staging",
"payment-service",
"cart-service-prod",
"cart-service-staging",
}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

// Filter for services ending with "-prod"
input := types.GetServicesInput{
Pattern: "-prod$",
}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
require.Len(t, output.Services, 2)
assert.Contains(t, output.Services, "frontend-prod")
assert.Contains(t, output.Services, "cart-service-prod")
}

func TestGetServicesHandler_Success_WithLimit(t *testing.T) {
testServices := []string{
"service-5",
"service-3",
"service-1",
"service-4",
"service-2",
}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

input := types.GetServicesInput{
Limit: 3,
}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
require.Len(t, output.Services, 3)
// After sorting, should return first 3 alphabetically
assert.Equal(t, []string{"service-1", "service-2", "service-3"}, output.Services)
}

func TestGetServicesHandler_Success_WithPatternAndLimit(t *testing.T) {
testServices := []string{
"payment-staging",
"frontend-prod",
"cart-staging",
"frontend-staging",
"payment-prod",
"cart-prod",
}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

// Filter for services ending with "-prod" and limit to 2
input := types.GetServicesInput{
Pattern: "-prod$",
Limit: 2,
}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
require.Len(t, output.Services, 2)
// After filtering and sorting, should return first 2 alphabetically
assert.Equal(t, []string{"cart-prod", "frontend-prod"}, output.Services)
}

func TestGetServicesHandler_Success_DefaultLimit(t *testing.T) {
// Create more than default limit services
testServices := make([]string, 150)
for i := 0; i < 150; i++ {
testServices[i] = "service-" + string(rune('a'+i%26))
}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

input := types.GetServicesInput{}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
// Should apply default limit of 100
assert.Len(t, output.Services, defaultServiceLimit)
}

func TestGetServicesHandler_Error_InvalidPattern(t *testing.T) {
testServices := []string{"frontend", "payment-service"}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

// Invalid regex pattern
input := types.GetServicesInput{
Pattern: "[invalid",
}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.Error(t, err)
assert.Contains(t, err.Error(), "invalid pattern")
assert.Empty(t, output.Services)
}

func TestGetServicesHandler_Error_StorageFailure(t *testing.T) {
mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return nil, errors.New("storage connection failed")
},
}

handler := &getServicesHandler{queryService: mock}

input := types.GetServicesInput{}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.Error(t, err)
assert.Contains(t, err.Error(), "failed to get services")
assert.Contains(t, err.Error(), "storage connection failed")
assert.Empty(t, output.Services)
}

func TestGetServicesHandler_Success_EmptyResult(t *testing.T) {
// No services in storage
mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return []string{}, nil
},
}

handler := &getServicesHandler{queryService: mock}

input := types.GetServicesInput{}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
assert.Empty(t, output.Services)
}

func TestGetServicesHandler_Success_NoMatchingPattern(t *testing.T) {
testServices := []string{"frontend", "payment-service", "cart-service"}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

// Pattern that doesn't match any service
input := types.GetServicesInput{
Pattern: "nonexistent",
}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
assert.Empty(t, output.Services)
}

func TestGetServicesHandler_Success_CaseInsensitivePattern(t *testing.T) {
testServices := []string{
"FrontEnd",
"PAYMENT-SERVICE",
"cart-service",
}

mock := &mockGetServicesQueryService{
getServicesFunc: func(_ context.Context) ([]string, error) {
return testServices, nil
},
}

handler := &getServicesHandler{queryService: mock}

// Case-insensitive pattern
input := types.GetServicesInput{
Pattern: "(?i)payment",
}

_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)

require.NoError(t, err)
require.Len(t, output.Services, 1)
assert.Equal(t, "PAYMENT-SERVICE", output.Services[0])
}
Loading
Loading