Skip to content

Commit d2f7aed

Browse files
Add get_traces tool with trace ID and service name support (#58)
* Add get_traces tool with trace ID and service name support - Add new get_traces tool for retrieving traces by trace ID or service name - Implement comprehensive parameter validation (exactly one of trace_id or service_name required) - Add complete unit test suite with validation, parsing, filtering, and integration tests - Register tool in MCP server with proper handler registration - Enable stateless HTTP mode for easy curl testing without session management - Support time range filtering, limits, and environment filtering - Reuse existing trace API infrastructure and response parsing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Add dual-mode HTTP server support and README documentation - Enable both stateless (/mcp) and stateful (/) endpoints simultaneously - Stateless mode on /mcp for easy curl testing without session management - Stateful mode on / for AI agents requiring bidirectional communication - Update README.md with complete get_traces tool documentation - Add usage examples, parameters, and integration instructions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Fix code formatting with gofmt - Apply gofmt formatting to get_traces.go and get_traces_test.go - Ensure consistent code style across the project - All tests continue to pass after formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Align HTTP server with MCP SDK recommendations - Replace dual endpoints with single unified /mcp endpoint (spec-compliant) - Enable automatic stateless mode detection per official MCP guidelines - Follow StreamableHTTP specification for single endpoint pattern - Simplify client configuration and improve protocol compliance - Maintain backward compatibility for curl testing and AI agents Based on MCP team recommendations moving toward "stateless by default" and single endpoint pattern for horizontal scaling and serverless deployments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Enable stateless mode on both / and /mcp endpoints - Add stateless MCP handlers on both root (/) and /mcp paths - Provide maximum client flexibility - clients can use either endpoint - Both endpoints support direct tool calls without session management - Perfect for curl testing, AI agents, and serverless deployments - Maintains MCP compliance while improving developer experience Verified working with both trace_id and service_name parameters: - GET /: ✅ Working (trace retrieval and service queries) - GET /mcp: ✅ Working (trace retrieval and service queries) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 6d037b4 commit d2f7aed

File tree

5 files changed

+756
-5
lines changed

5 files changed

+756
-5
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ IDEs. Implements the following MCP
4545

4646
**Traces Management:**
4747

48+
- `get_traces`: Retrieve traces by trace ID or service name with time range filtering.
4849
- `get_service_traces`: Query traces for a specific service with filtering options for span kinds, status codes, and other trace attributes.
4950
- `get_trace_attributes`: Get available trace attributes (series) for a specified time window.
5051

@@ -292,6 +293,30 @@ Returns:
292293
- Log Attributes: Standard log fields like service, severity, body, level, etc.
293294
- Resource Attributes: Resource-related fields prefixed with "resource_" like resource_k8s.pod.name, resource_service.name, etc.
294295

296+
### get_traces
297+
298+
Retrieve traces from Last9 by trace ID or service name. This tool allows you to get specific traces either by providing a trace ID for a single trace, or by providing a service name to get all traces for that service within a time range.
299+
300+
Parameters:
301+
302+
- `trace_id` (string, optional): Specific trace ID to retrieve. Cannot be used with service_name.
303+
- `service_name` (string, optional): Name of service to get traces for. Cannot be used with trace_id.
304+
- `lookback_minutes` (integer, optional): Number of minutes to look back from now. Default: 60 minutes. Examples: 60, 30, 15.
305+
- `start_time_iso` (string, optional): Start time in ISO format (YYYY-MM-DD HH:MM:SS). Leave empty to default to now - lookback_minutes.
306+
- `end_time_iso` (string, optional): End time in ISO format (YYYY-MM-DD HH:MM:SS). Leave empty to default to current time.
307+
- `limit` (integer, optional): Maximum number of traces to return. Default: 10. Range: 1-100.
308+
- `env` (string, optional): Environment to filter by. Use "get_service_environments" tool to get available environments.
309+
310+
Usage rules:
311+
- Exactly one of `trace_id` or `service_name` must be provided (not both, not neither)
312+
- Time range filtering only applies when using `service_name`
313+
314+
Examples:
315+
- trace_id="abc123def456" - retrieves the specific trace
316+
- service_name="payment-service" + lookback_minutes=30 - gets all payment service traces from last 30 minutes
317+
318+
Returns trace data including trace IDs, spans, duration, timestamps, and status information.
319+
295320
### get_service_traces
296321

297322
Query traces for a specific service with filtering options for span kinds, status codes, and other trace attributes. This tool retrieves distributed tracing data for debugging performance issues, understanding request flows, and analyzing service interactions.

http_server.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,21 @@ func (h *HTTPServer) Start() error {
5151
// Create a mux to handle multiple endpoints
5252
mux := http.NewServeMux()
5353

54-
// Create the streamable HTTP handler for the main MCP endpoint
55-
mcpHandler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
54+
// Create stateless MCP handler for maximum client compatibility
55+
// Enables direct tool calls without session management - perfect for curl testing,
56+
// serverless deployments, and horizontal scaling per MCP team recommendations
57+
statelessHandler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
5658
return h.server.Server
57-
}, nil)
59+
}, &mcp.StreamableHTTPOptions{
60+
Stateless: true, // Enable stateless mode - no session management needed
61+
GetSessionID: func() string {
62+
return "" // No session ID header required
63+
},
64+
})
5865

59-
// Register handlers
60-
mux.Handle("/", mcpHandler) // Main MCP endpoint
66+
// Register handlers on both root and /mcp paths for maximum client flexibility
67+
mux.Handle("/", statelessHandler) // Root endpoint for standard MCP clients
68+
mux.Handle("/mcp", statelessHandler) // /mcp endpoint for explicit MCP usage
6169
mux.HandleFunc("/health", h.handleHealth)
6270

6371
// Create HTTP server with timeouts
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package traces
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strconv"
12+
13+
"last9-mcp/internal/models"
14+
"last9-mcp/internal/utils"
15+
16+
"github.com/modelcontextprotocol/go-sdk/mcp"
17+
)
18+
19+
// GetTracesDescription provides the description for the get traces tool
20+
const GetTracesDescription = `Retrieve traces from Last9 by trace ID or service name.
21+
22+
This tool allows you to get specific traces either by providing a trace ID for a single trace,
23+
or by providing a service name to get all traces for that service within a time range.
24+
25+
Parameters:
26+
- trace_id: (Optional) Specific trace ID to retrieve. Cannot be used with service_name.
27+
- service_name: (Optional) Name of service to get traces for. Cannot be used with trace_id.
28+
- lookback_minutes: (Optional) Number of minutes to look back from now. Default: 60 minutes
29+
- start_time_iso: (Optional) Start time in ISO format (YYYY-MM-DD HH:MM:SS)
30+
- end_time_iso: (Optional) End time in ISO format (YYYY-MM-DD HH:MM:SS)
31+
- limit: (Optional) Maximum number of traces to return. Default: 10
32+
- env: (Optional) Environment to filter by. Use "get_service_environments" tool to get available environments.
33+
34+
Examples:
35+
1. trace_id="abc123def456" - retrieves the specific trace
36+
2. service_name="payment-service" + lookback_minutes=30 - gets all payment service traces from last 30 minutes
37+
38+
Returns trace data including trace IDs, spans, duration, timestamps, and status information.`
39+
40+
// GetTracesArgs defines the input structure for getting traces
41+
type GetTracesArgs struct {
42+
TraceID string `json:"trace_id,omitempty" jsonschema:"Specific trace ID to retrieve"`
43+
ServiceName string `json:"service_name,omitempty" jsonschema:"Name of service to get traces for"`
44+
LookbackMinutes float64 `json:"lookback_minutes,omitempty" jsonschema:"Number of minutes to look back from now (default: 60, range: 1-1440)"`
45+
StartTimeISO string `json:"start_time_iso,omitempty" jsonschema:"Start time in ISO format (YYYY-MM-DD HH:MM:SS). Leave empty to default to now - lookback_minutes."`
46+
EndTimeISO string `json:"end_time_iso,omitempty" jsonschema:"End time in ISO format (YYYY-MM-DD HH:MM:SS). Leave empty to default to current time."`
47+
Limit float64 `json:"limit,omitempty" jsonschema:"Maximum number of traces to return (default: 10, range: 1-100)"`
48+
Env string `json:"env,omitempty" jsonschema:"Environment to filter by. Empty string if environment is unknown."`
49+
}
50+
51+
// GetTracesQueryParams holds the parsed and validated parameters
52+
type GetTracesQueryParams struct {
53+
TraceID string
54+
ServiceName string
55+
LookbackMinutes int
56+
Region string
57+
Limit int
58+
Env string
59+
}
60+
61+
// validateGetTracesArgs validates the input arguments
62+
func validateGetTracesArgs(args GetTracesArgs) error {
63+
// Exactly one of trace_id or service_name must be provided
64+
if args.TraceID == "" && args.ServiceName == "" {
65+
return errors.New("either trace_id or service_name must be provided")
66+
}
67+
68+
if args.TraceID != "" && args.ServiceName != "" {
69+
return errors.New("cannot specify both trace_id and service_name - use only one")
70+
}
71+
72+
// Validate limits
73+
if args.LookbackMinutes > 0 && (args.LookbackMinutes < 1 || args.LookbackMinutes > 1440) {
74+
return errors.New("lookback_minutes must be between 1 and 1440 (24 hours)")
75+
}
76+
77+
if args.Limit > 0 && (args.Limit < 1 || args.Limit > 100) {
78+
return errors.New("limit must be between 1 and 100")
79+
}
80+
81+
return nil
82+
}
83+
84+
// parseGetTracesParams extracts and validates parameters from input struct
85+
func parseGetTracesParams(args GetTracesArgs, cfg models.Config) (*GetTracesQueryParams, error) {
86+
// Validate arguments
87+
if err := validateGetTracesArgs(args); err != nil {
88+
return nil, err
89+
}
90+
91+
// Parse parameters with defaults
92+
queryParams := &GetTracesQueryParams{
93+
TraceID: args.TraceID,
94+
ServiceName: args.ServiceName,
95+
LookbackMinutes: LookbackMinutesDefault,
96+
Region: utils.GetDefaultRegion(cfg.BaseURL),
97+
Limit: LimitDefault,
98+
Env: args.Env,
99+
}
100+
101+
// Override defaults with provided values
102+
if args.LookbackMinutes != 0 {
103+
queryParams.LookbackMinutes = int(args.LookbackMinutes)
104+
}
105+
if args.Limit != 0 {
106+
queryParams.Limit = int(args.Limit)
107+
}
108+
109+
return queryParams, nil
110+
}
111+
112+
// buildGetTracesFilters creates the filter conditions for the trace query
113+
func buildGetTracesFilters(params *GetTracesQueryParams) []map[string]interface{} {
114+
var filters []map[string]interface{}
115+
116+
// Filter by trace ID if provided
117+
if params.TraceID != "" {
118+
filters = append(filters, map[string]interface{}{
119+
"$eq": []interface{}{"TraceId", params.TraceID},
120+
})
121+
}
122+
123+
// Filter by service name if provided
124+
if params.ServiceName != "" {
125+
filters = append(filters, map[string]interface{}{
126+
"$eq": []interface{}{"ServiceName", params.ServiceName},
127+
})
128+
}
129+
130+
// Add environment filter if provided
131+
if params.Env != "" {
132+
filters = append(filters, map[string]interface{}{
133+
"$eq": []interface{}{"resource.attributes.deployment.environment", params.Env},
134+
})
135+
}
136+
137+
return filters
138+
}
139+
140+
// buildGetTracesRequestURL constructs the API endpoint URL with query parameters
141+
func buildGetTracesRequestURL(cfg models.Config, params *GetTracesQueryParams, startTime, endTime int64) (*url.URL, error) {
142+
u, err := url.Parse(cfg.APIBaseURL + "/cat/api/traces/v2/query_range/json")
143+
if err != nil {
144+
return nil, fmt.Errorf("failed to parse URL: %w", err)
145+
}
146+
147+
q := u.Query()
148+
q.Set("region", params.Region)
149+
q.Set("start", strconv.FormatInt(startTime, 10))
150+
q.Set("end", strconv.FormatInt(endTime, 10))
151+
q.Set("limit", strconv.Itoa(params.Limit))
152+
q.Set("order", "Duration")
153+
q.Set("direction", "backward")
154+
u.RawQuery = q.Encode()
155+
156+
return u, nil
157+
}
158+
159+
// GetTracesHandler creates a handler for getting traces by ID or service name
160+
func GetTracesHandler(client *http.Client, cfg models.Config) func(context.Context, *mcp.CallToolRequest, GetTracesArgs) (*mcp.CallToolResult, any, error) {
161+
return func(ctx context.Context, req *mcp.CallToolRequest, args GetTracesArgs) (*mcp.CallToolResult, any, error) {
162+
// Parse and validate parameters
163+
queryParams, err := parseGetTracesParams(args, cfg)
164+
if err != nil {
165+
return nil, nil, err
166+
}
167+
168+
// Prepare arguments map for GetTimeRange function
169+
arguments := make(map[string]interface{})
170+
if args.StartTimeISO != "" {
171+
arguments["start_time_iso"] = args.StartTimeISO
172+
}
173+
if args.EndTimeISO != "" {
174+
arguments["end_time_iso"] = args.EndTimeISO
175+
}
176+
177+
// Get time range
178+
startTime, endTime, err := utils.GetTimeRange(arguments, queryParams.LookbackMinutes)
179+
if err != nil {
180+
return nil, nil, err
181+
}
182+
183+
// Build request URL
184+
requestURL, err := buildGetTracesRequestURL(cfg, queryParams, startTime.Unix(), endTime.Unix())
185+
if err != nil {
186+
return nil, nil, err
187+
}
188+
189+
// Build filters
190+
filters := buildGetTracesFilters(queryParams)
191+
192+
// Create HTTP request using existing pattern
193+
httpReq, err := createTraceRequest(ctx, requestURL, filters, cfg)
194+
if err != nil {
195+
return nil, nil, err
196+
}
197+
198+
// Execute request
199+
resp, err := client.Do(httpReq)
200+
if err != nil {
201+
return nil, nil, fmt.Errorf("request failed: %w", err)
202+
}
203+
defer resp.Body.Close()
204+
205+
if resp.StatusCode != http.StatusOK {
206+
body, _ := io.ReadAll(resp.Body)
207+
return nil, nil, fmt.Errorf("API request failed with status %d: %s",
208+
resp.StatusCode, string(body))
209+
}
210+
211+
var result map[string]interface{}
212+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
213+
return nil, nil, fmt.Errorf("failed to decode response: %w", err)
214+
}
215+
216+
// Transform raw response to structured TraceQueryResponse (reusing existing function)
217+
traceResponse := transformToTraceQueryResponse(result)
218+
219+
// Add context about the query type
220+
if queryParams.TraceID != "" {
221+
traceResponse.Message = fmt.Sprintf("Retrieved trace data for trace ID: %s", queryParams.TraceID)
222+
} else {
223+
traceResponse.Message = fmt.Sprintf("Retrieved %d traces for service: %s", len(traceResponse.Data), queryParams.ServiceName)
224+
}
225+
226+
jsonData, err := json.Marshal(traceResponse)
227+
if err != nil {
228+
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
229+
}
230+
231+
return &mcp.CallToolResult{
232+
Content: []mcp.Content{
233+
&mcp.TextContent{
234+
Text: string(jsonData),
235+
},
236+
},
237+
}, nil, nil
238+
}
239+
}

0 commit comments

Comments
 (0)