Skip to content

Commit dbebadc

Browse files
Copilotyurishkuro
authored andcommitted
[mcp] get_services tool for service discovery (jaegertracing#7864)
## Implementation Complete: get_services MCP Tool (Part of jaegertracing#7827) - [x] Create types file (`get_services.go`) in `internal/types/` directory - [x] Create handler file (`get_services.go`) in `internal/handlers/` directory - [x] Create comprehensive test file (`get_services_test.go`) with 12 test cases - [x] Register `get_services` tool at the top of `registerTools()` function in `server.go` - [x] Update ADR-002 to mark `get_services` as completed in Phase 2 - [x] Run `make fmt` - ✅ passed successfully - [x] Run `make lint` - ✅ passed with 0 issues - [x] Run `make test` - ✅ all tests pass (12/12 for get_services) - [x] Verify code coverage > 95% - ✅ 100% coverage for both functions - [x] Final verification - ✅ module compiles and all tests pass - [x] Sort services before applying limit for consistent results - [x] Add test for NewGetServicesHandler constructor function ## Summary Successfully implemented the `get_services` MCP tool as requested in issue jaegertracing#7827. This tool enables users to discover available service names, which is essential for using `search_traces` since it requires a service_name parameter. ### Key Features: - **Service Discovery**: Lists all available service names from storage - **Pattern Filtering**: Supports regex pattern matching for filtering services - **Limit Control**: Configurable limit (default: 100 services) - **Consistent Ordering**: Services are sorted alphabetically before applying limit - **Comprehensive Error Handling**: Invalid patterns, storage failures, edge cases - **100% Test Coverage**: 12 test cases covering all scenarios including constructor ### Files Changed: - **3 new files** created (types, handler, tests) - **2 files** modified (server.go, ADR-002) ### Tool Position: Registered at the **top** of `registerTools()` as required, making it the first tool users encounter. This is critical because `search_traces` depends on service names discovered through this tool. ### Testing: - All 12 get_services tests pass - All existing MCP tests continue to pass - No regressions introduced - Code quality verified with fmt and lint - 100% code coverage for all functions This implementation completes the requirements from issue jaegertracing#7827. <!-- START COPILOT CODING AGENT SUFFIX --> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > jaegertracing#7827 > > we're missing get_service tool, whichout which search_traces is not useful since it requires a service name. Add this tool to the ADR-002 in all relevant places and implement. The input should accept an optional regex to filter the list of services. The tool should be at the top inside registerTools() function. > > State Part of jaegertracing#7827 in the PR description. Run make fmt make lint and make test to success everytime before pushing to remove branch. Ensure code coverage for new code over 95%. Update ADR to reflect progress. </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yurishkuro <3523016+yurishkuro@users.noreply.github.com>
1 parent e920827 commit dbebadc

File tree

5 files changed

+431
-3
lines changed

5 files changed

+431
-3
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 The Jaeger Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package handlers
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"regexp"
10+
"sort"
11+
12+
"github.com/modelcontextprotocol/go-sdk/mcp"
13+
14+
"github.com/jaegertracing/jaeger/cmd/jaeger/internal/extension/jaegermcp/internal/types"
15+
"github.com/jaegertracing/jaeger/cmd/jaeger/internal/extension/jaegerquery/querysvc"
16+
)
17+
18+
const defaultServiceLimit = 100
19+
20+
// queryServiceGetServicesInterface defines the interface we need from QueryService for testing
21+
type queryServiceGetServicesInterface interface {
22+
GetServices(ctx context.Context) ([]string, error)
23+
}
24+
25+
// getServicesHandler implements the get_services MCP tool.
26+
// This tool lists available service names, optionally filtered by a regex pattern.
27+
type getServicesHandler struct {
28+
queryService queryServiceGetServicesInterface
29+
}
30+
31+
// NewGetServicesHandler creates a new get_services handler and returns the handler function.
32+
func NewGetServicesHandler(
33+
queryService *querysvc.QueryService,
34+
) mcp.ToolHandlerFor[types.GetServicesInput, types.GetServicesOutput] {
35+
h := &getServicesHandler{
36+
queryService: queryService,
37+
}
38+
return h.handle
39+
}
40+
41+
// handle processes the get_services tool request.
42+
func (h *getServicesHandler) handle(
43+
ctx context.Context,
44+
_ *mcp.CallToolRequest,
45+
input types.GetServicesInput,
46+
) (*mcp.CallToolResult, types.GetServicesOutput, error) {
47+
// Get all services from storage
48+
services, err := h.queryService.GetServices(ctx)
49+
if err != nil {
50+
return nil, types.GetServicesOutput{}, fmt.Errorf("failed to get services: %w", err)
51+
}
52+
53+
// Apply pattern filter if provided
54+
if input.Pattern != "" {
55+
re, err := regexp.Compile(input.Pattern)
56+
if err != nil {
57+
return nil, types.GetServicesOutput{}, fmt.Errorf("invalid pattern: %w", err)
58+
}
59+
60+
filtered := make([]string, 0, len(services))
61+
for _, service := range services {
62+
if re.MatchString(service) {
63+
filtered = append(filtered, service)
64+
}
65+
}
66+
services = filtered
67+
}
68+
69+
// Sort services for consistent ordering
70+
sort.Strings(services)
71+
72+
// Apply limit
73+
limit := input.Limit
74+
if limit <= 0 {
75+
limit = defaultServiceLimit
76+
}
77+
if len(services) > limit {
78+
services = services[:limit]
79+
}
80+
81+
return nil, types.GetServicesOutput{
82+
Services: services,
83+
}, nil
84+
}
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
// Copyright (c) 2026 The Jaeger Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package handlers
5+
6+
import (
7+
"context"
8+
"errors"
9+
"testing"
10+
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/jaegertracing/jaeger/cmd/jaeger/internal/extension/jaegermcp/internal/types"
16+
)
17+
18+
// mockGetServicesQueryService mocks the GetServices method for testing
19+
type mockGetServicesQueryService struct {
20+
getServicesFunc func(ctx context.Context) ([]string, error)
21+
}
22+
23+
func (m *mockGetServicesQueryService) GetServices(ctx context.Context) ([]string, error) {
24+
if m.getServicesFunc != nil {
25+
return m.getServicesFunc(ctx)
26+
}
27+
return nil, nil
28+
}
29+
30+
func TestGetServicesHandler_Success_AllServices(t *testing.T) {
31+
testServices := []string{
32+
"frontend",
33+
"payment-service",
34+
"cart-service",
35+
"user-service",
36+
}
37+
38+
mock := &mockGetServicesQueryService{
39+
getServicesFunc: func(_ context.Context) ([]string, error) {
40+
return testServices, nil
41+
},
42+
}
43+
44+
handler := &getServicesHandler{queryService: mock}
45+
46+
input := types.GetServicesInput{}
47+
48+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
49+
50+
require.NoError(t, err)
51+
assert.Equal(t, testServices, output.Services)
52+
}
53+
54+
func TestGetServicesHandler_Success_WithPattern(t *testing.T) {
55+
testServices := []string{
56+
"frontend",
57+
"payment-service",
58+
"payment-gateway",
59+
"cart-service",
60+
"user-service",
61+
}
62+
63+
mock := &mockGetServicesQueryService{
64+
getServicesFunc: func(_ context.Context) ([]string, error) {
65+
return testServices, nil
66+
},
67+
}
68+
69+
handler := &getServicesHandler{queryService: mock}
70+
71+
// Filter for services containing "payment"
72+
input := types.GetServicesInput{
73+
Pattern: "payment",
74+
}
75+
76+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
77+
78+
require.NoError(t, err)
79+
require.Len(t, output.Services, 2)
80+
assert.Contains(t, output.Services, "payment-service")
81+
assert.Contains(t, output.Services, "payment-gateway")
82+
}
83+
84+
func TestGetServicesHandler_Success_WithRegexPattern(t *testing.T) {
85+
testServices := []string{
86+
"frontend-prod",
87+
"frontend-staging",
88+
"payment-service",
89+
"cart-service-prod",
90+
"cart-service-staging",
91+
}
92+
93+
mock := &mockGetServicesQueryService{
94+
getServicesFunc: func(_ context.Context) ([]string, error) {
95+
return testServices, nil
96+
},
97+
}
98+
99+
handler := &getServicesHandler{queryService: mock}
100+
101+
// Filter for services ending with "-prod"
102+
input := types.GetServicesInput{
103+
Pattern: "-prod$",
104+
}
105+
106+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
107+
108+
require.NoError(t, err)
109+
require.Len(t, output.Services, 2)
110+
assert.Contains(t, output.Services, "frontend-prod")
111+
assert.Contains(t, output.Services, "cart-service-prod")
112+
}
113+
114+
func TestGetServicesHandler_Success_WithLimit(t *testing.T) {
115+
testServices := []string{
116+
"service-5",
117+
"service-3",
118+
"service-1",
119+
"service-4",
120+
"service-2",
121+
}
122+
123+
mock := &mockGetServicesQueryService{
124+
getServicesFunc: func(_ context.Context) ([]string, error) {
125+
return testServices, nil
126+
},
127+
}
128+
129+
handler := &getServicesHandler{queryService: mock}
130+
131+
input := types.GetServicesInput{
132+
Limit: 3,
133+
}
134+
135+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
136+
137+
require.NoError(t, err)
138+
require.Len(t, output.Services, 3)
139+
// After sorting, should return first 3 alphabetically
140+
assert.Equal(t, []string{"service-1", "service-2", "service-3"}, output.Services)
141+
}
142+
143+
func TestGetServicesHandler_Success_WithPatternAndLimit(t *testing.T) {
144+
testServices := []string{
145+
"payment-staging",
146+
"frontend-prod",
147+
"cart-staging",
148+
"frontend-staging",
149+
"payment-prod",
150+
"cart-prod",
151+
}
152+
153+
mock := &mockGetServicesQueryService{
154+
getServicesFunc: func(_ context.Context) ([]string, error) {
155+
return testServices, nil
156+
},
157+
}
158+
159+
handler := &getServicesHandler{queryService: mock}
160+
161+
// Filter for services ending with "-prod" and limit to 2
162+
input := types.GetServicesInput{
163+
Pattern: "-prod$",
164+
Limit: 2,
165+
}
166+
167+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
168+
169+
require.NoError(t, err)
170+
require.Len(t, output.Services, 2)
171+
// After filtering and sorting, should return first 2 alphabetically
172+
assert.Equal(t, []string{"cart-prod", "frontend-prod"}, output.Services)
173+
}
174+
175+
func TestGetServicesHandler_Success_DefaultLimit(t *testing.T) {
176+
// Create more than default limit services
177+
testServices := make([]string, 150)
178+
for i := 0; i < 150; i++ {
179+
testServices[i] = "service-" + string(rune('a'+i%26))
180+
}
181+
182+
mock := &mockGetServicesQueryService{
183+
getServicesFunc: func(_ context.Context) ([]string, error) {
184+
return testServices, nil
185+
},
186+
}
187+
188+
handler := &getServicesHandler{queryService: mock}
189+
190+
input := types.GetServicesInput{}
191+
192+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
193+
194+
require.NoError(t, err)
195+
// Should apply default limit of 100
196+
assert.Len(t, output.Services, defaultServiceLimit)
197+
}
198+
199+
func TestGetServicesHandler_Error_InvalidPattern(t *testing.T) {
200+
testServices := []string{"frontend", "payment-service"}
201+
202+
mock := &mockGetServicesQueryService{
203+
getServicesFunc: func(_ context.Context) ([]string, error) {
204+
return testServices, nil
205+
},
206+
}
207+
208+
handler := &getServicesHandler{queryService: mock}
209+
210+
// Invalid regex pattern
211+
input := types.GetServicesInput{
212+
Pattern: "[invalid",
213+
}
214+
215+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
216+
217+
require.Error(t, err)
218+
assert.Contains(t, err.Error(), "invalid pattern")
219+
assert.Empty(t, output.Services)
220+
}
221+
222+
func TestGetServicesHandler_Error_StorageFailure(t *testing.T) {
223+
mock := &mockGetServicesQueryService{
224+
getServicesFunc: func(_ context.Context) ([]string, error) {
225+
return nil, errors.New("storage connection failed")
226+
},
227+
}
228+
229+
handler := &getServicesHandler{queryService: mock}
230+
231+
input := types.GetServicesInput{}
232+
233+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
234+
235+
require.Error(t, err)
236+
assert.Contains(t, err.Error(), "failed to get services")
237+
assert.Contains(t, err.Error(), "storage connection failed")
238+
assert.Empty(t, output.Services)
239+
}
240+
241+
func TestGetServicesHandler_Success_EmptyResult(t *testing.T) {
242+
// No services in storage
243+
mock := &mockGetServicesQueryService{
244+
getServicesFunc: func(_ context.Context) ([]string, error) {
245+
return []string{}, nil
246+
},
247+
}
248+
249+
handler := &getServicesHandler{queryService: mock}
250+
251+
input := types.GetServicesInput{}
252+
253+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
254+
255+
require.NoError(t, err)
256+
assert.Empty(t, output.Services)
257+
}
258+
259+
func TestGetServicesHandler_Success_NoMatchingPattern(t *testing.T) {
260+
testServices := []string{"frontend", "payment-service", "cart-service"}
261+
262+
mock := &mockGetServicesQueryService{
263+
getServicesFunc: func(_ context.Context) ([]string, error) {
264+
return testServices, nil
265+
},
266+
}
267+
268+
handler := &getServicesHandler{queryService: mock}
269+
270+
// Pattern that doesn't match any service
271+
input := types.GetServicesInput{
272+
Pattern: "nonexistent",
273+
}
274+
275+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
276+
277+
require.NoError(t, err)
278+
assert.Empty(t, output.Services)
279+
}
280+
281+
func TestGetServicesHandler_Success_CaseInsensitivePattern(t *testing.T) {
282+
testServices := []string{
283+
"FrontEnd",
284+
"PAYMENT-SERVICE",
285+
"cart-service",
286+
}
287+
288+
mock := &mockGetServicesQueryService{
289+
getServicesFunc: func(_ context.Context) ([]string, error) {
290+
return testServices, nil
291+
},
292+
}
293+
294+
handler := &getServicesHandler{queryService: mock}
295+
296+
// Case-insensitive pattern
297+
input := types.GetServicesInput{
298+
Pattern: "(?i)payment",
299+
}
300+
301+
_, output, err := handler.handle(context.Background(), &mcp.CallToolRequest{}, input)
302+
303+
require.NoError(t, err)
304+
require.Len(t, output.Services, 1)
305+
assert.Equal(t, "PAYMENT-SERVICE", output.Services[0])
306+
}
307+
308+
func TestNewGetServicesHandler(t *testing.T) {
309+
// Test that NewGetServicesHandler returns a valid handler function
310+
handler := NewGetServicesHandler(nil)
311+
assert.NotNil(t, handler)
312+
}

0 commit comments

Comments
 (0)