diff --git a/AGENTS.md b/AGENTS.md index 68e4e76..7d6a04d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,6 +268,38 @@ export TELEMETRY_ENABLED=false **Sentry DSN:** `https://445c4c2185068fa980b83ddbe4bf1fd7@o188824.ingest.us.sentry.io/4510306572828672` +### MCP Tracing + +The project includes automatic tracing for MCP tool calls following [OpenTelemetry MCP Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/pull/2083). + +**Key Features:** + +- Automatic span creation for tool calls +- Detailed attributes (method name, tool name, arguments, results) +- Error capture and correlation +- Compatible with Sentry performance monitoring + +**Implementation:** + +All MCP tools are automatically wrapped with Sentry tracing using the `WithSentryTracing` wrapper: + +```go +mcp.AddTool(server, &mcp.Tool{ + Name: "my_tool", + Description: "Tool description", +}, WithSentryTracing("my_tool", m.handleMyTool)) +``` + +**Span Attributes:** + +Each tool call creates a span with: + +- Operation: `mcp.server` +- Name: `tools/call {tool_name}` +- Attributes: `mcp.method.name`, `mcp.tool.name`, `mcp.request.argument.*`, `mcp.tool.result.*` + +See `docs/MCP_TRACING.md` for complete documentation on span conventions, attributes, and examples. + ## Security Considerations - **No credentials in code**: Never commit API keys or certificates @@ -303,15 +335,13 @@ func (m *MCPServer) handleNewTool(ctx context.Context, req *mcp.CallToolRequest, } ``` -3. Register tool in `internal/cli/mcp/server.go`: +3. Register tool in `internal/cli/mcp/server.go` with Sentry tracing: ```go mcp.AddTool(server, &mcp.Tool{ Name: "new_tool", Description: "Description of what the tool does", -}, func(ctx context.Context, req *mcp.CallToolRequest, args NewToolArgs) (*mcp.CallToolResult, any, error) { - return m.handleNewTool(ctx, req, args) -}) +}, WithSentryTracing("new_tool", m.handleNewTool)) ``` 4. Test the tool: diff --git a/docs/MCP_TRACING.md b/docs/MCP_TRACING.md new file mode 100644 index 0000000..fb18dab --- /dev/null +++ b/docs/MCP_TRACING.md @@ -0,0 +1,260 @@ +# MCP Tracing with Sentry + +This document describes the MCP (Model Context Protocol) tracing integration with Sentry for the GitHub Actions Utils CLI. + +## Quick Start + +Automatic instrumentation for MCP tool calls that creates Sentry spans following OpenTelemetry conventions. + +### Usage + +Register a tool with Sentry tracing: + +```go +mcp.AddTool(server, &mcp.Tool{ + Name: "my_tool", + Description: "My awesome tool", +}, WithSentryTracing("my_tool", m.handleMyTool)) +``` + +That's it! The tool is now automatically traced. + +### What Gets Captured + +Every tool call creates a span with: + +- **Operation**: `mcp.server` +- **Name**: `tools/call my_tool` +- **Attributes**: + - Method name (`mcp.method.name`) + - Tool name (`mcp.tool.name`) + - All arguments (`mcp.request.argument.*`) + - Result metadata (`mcp.tool.result.*`) + - Transport info (`mcp.transport`, `network.transport`) + - Error status (`mcp.tool.result.is_error`) + +### Benefits + +✅ **Zero boilerplate**: One wrapper function, that's it\ +✅ **Type-safe**: Uses Go generics\ +✅ **Automatic**: Arguments and results captured automatically\ +✅ **Standard**: Follows OpenTelemetry MCP conventions\ +✅ **Production-ready**: Error capture, proper span lifecycle + +### Disable Telemetry + +```bash +export TELEMETRY_ENABLED=false +``` + +## Overview + +The MCP Server integration automatically instruments tool calls with Sentry spans, following the [OpenTelemetry MCP Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/pull/2083). This provides comprehensive observability for MCP tool execution, including: + +- Automatic span creation for tool calls +- Detailed attributes following MCP semantic conventions +- Error capture and correlation +- Tool result tracking + +## Implementation + +The implementation is based on the Sentry JavaScript SDK's MCP integration, adapted for Go. Key files: + +- `internal/cli/mcp/sentry.go` - Tracing wrapper and attribute extraction +- `internal/cli/mcp/server.go` - Tool registration with tracing + +## Detailed Usage + +### Wrapping a Tool Handler + +Use the `WithSentryTracing` wrapper when registering tools: + +```go +mcp.AddTool(server, &mcp.Tool{ + Name: "my_tool", + Description: "Does something useful", +}, WithSentryTracing("my_tool", m.handleMyTool)) +``` + +The wrapper: + +1. Creates a span for the tool execution +2. Sets MCP-specific attributes +3. Captures tool arguments +4. Tracks results and errors +5. Reports to Sentry + +### Example + +See `internal/cli/mcp/server.go` for a complete example: + +```go +func (m *MCPServer) RegisterTools(server *mcp.Server) { + // Register get_action_parameters tool with Sentry tracing + mcp.AddTool(server, &mcp.Tool{ + Name: "get_action_parameters", + Description: "Fetch and parse a GitHub Action's action.yml file...", + }, WithSentryTracing("get_action_parameters", m.handleGetActionParameters)) +} +``` + +## Span Conventions + +All spans follow the OpenTelemetry MCP semantic conventions: + +### Span Name + +Tool call spans use the format: `tools/call {tool_name}` + +Examples: + +- `tools/call get_action_parameters` +- `tools/call my_custom_tool` + +### Span Operation + +All MCP tool spans use the operation: `mcp.server` + +### Common Attributes + +All spans include these attributes: + +| Attribute | Type | Description | Example | +| -------------------------- | ------ | ---------------------------- | ---------------------------- | +| `mcp.method.name` | string | The MCP method name | `"tools/call"` | +| `mcp.tool.name` | string | The tool being called | `"get_action_parameters"` | +| `mcp.transport` | string | Transport method used | `"stdio"` | +| `network.transport` | string | OSI transport layer protocol | `"pipe"` | +| `network.protocol.version` | string | JSON-RPC version | `"2.0"` | +| `sentry.origin` | string | Sentry origin identifier | `"auto.function.mcp_server"` | +| `sentry.source` | string | Sentry source type | `"route"` | + +### Tool-Specific Attributes + +#### Tool Arguments + +Tool arguments are automatically extracted and set with the prefix `mcp.request.argument`: + +``` +mcp.request.argument.actionref = "actions/checkout@v5" +``` + +The argument names are: + +- Extracted from JSON struct tags +- Converted to lowercase +- Prefixed with `mcp.request.argument.` + +#### Tool Results + +Result metadata is captured: + +| Attribute | Type | Description | Example | +| ------------------------------- | ------- | ---------------------------------- | ---------- | +| `mcp.tool.result.is_error` | boolean | Whether the tool returned an error | `false` | +| `mcp.tool.result.content_count` | int | Number of content items returned | `1` | +| `mcp.tool.result.content` | string | JSON array of content types | `["text"]` | + +### Request Metadata + +If available, the following are extracted from the request: + +| Attribute | Type | Description | +| ---------------- | ------ | ------------------------- | +| `mcp.request.id` | string | Unique request identifier | +| `mcp.session.id` | string | MCP session identifier | + +## Span Status + +Spans are marked with appropriate status: + +- `ok` - Tool executed successfully +- `internal_error` - Tool returned an error + +## Error Capture + +When a tool handler returns an error: + +1. The span status is set to `internal_error` +2. `mcp.tool.result.is_error` is set to `true` +3. The error is captured to Sentry with full context +4. The error is propagated to the MCP client + +## Example Span Data + +Here's an example of what a tool call span looks like in Sentry: + +```json +{ + "op": "mcp.server", + "description": "tools/call get_action_parameters", + "status": "ok", + "data": { + "mcp.method.name": "tools/call", + "mcp.tool.name": "get_action_parameters", + "mcp.transport": "stdio", + "network.transport": "pipe", + "network.protocol.version": "2.0", + "mcp.request.argument.actionref": "actions/checkout@v5", + "mcp.tool.result.is_error": false, + "mcp.tool.result.content_count": 1, + "mcp.tool.result.content": "[\"text\"]", + "sentry.origin": "auto.function.mcp_server", + "sentry.source": "route" + } +} +``` + +## Viewing Traces + +In Sentry: + +1. Go to **Performance** → **Traces** +2. Filter by operation: `mcp.server` +3. See tool calls with full context + +## Comparison with JavaScript SDK + +This implementation closely follows the Sentry JavaScript SDK's MCP integration: + +### Similarities + +- Follows same OpenTelemetry MCP conventions +- Uses identical attribute names and values +- Implements same span creation patterns +- Captures results and errors similarly + +### Differences + +- **Language**: Go vs TypeScript +- **SDK Integration**: Direct wrapper vs transport interception + - JS: Wraps transport layer to intercept all messages + - Go: Wraps individual tool handlers (simpler, more idiomatic) +- **Type Safety**: Go uses generics for type-safe wrappers +- **Session Management**: Not yet implemented (stateless server) + +### Why the Difference? + +The Go MCP SDK has a different architecture: + +- Tool handlers are registered directly with type safety +- No need to wrap transport layer for basic tool tracing +- Simpler approach that achieves the same observability goals + +## Future Enhancements + +Potential improvements to consider: + +1. **Session Management**: Track client/server info across requests +2. **Transport Wrapping**: Intercept all MCP messages (not just tool calls) +3. **Resource Tracing**: Add spans for resource access +4. **Prompt Tracing**: Add spans for prompt requests +5. **Notification Tracing**: Track MCP notifications +6. **Result Content**: Optionally capture full result payloads (with PII filtering) + +## References + +- [OpenTelemetry MCP Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/pull/2083) +- [MCP Specification](https://modelcontextprotocol.io/) +- [Sentry Go SDK](https://docs.sentry.io/platforms/go/) +- [Sentry JavaScript MCP Integration](https://github.com/getsentry/sentry-javascript/tree/develop/packages/core/src/integrations/mcp-server) diff --git a/internal/cli/mcp/sentry.go b/internal/cli/mcp/sentry.go new file mode 100644 index 0000000..10908a6 --- /dev/null +++ b/internal/cli/mcp/sentry.go @@ -0,0 +1,260 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/getsentry/sentry-go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// MCP Attribute Constants +// Based on OpenTelemetry MCP Semantic Conventions +// See: https://github.com/open-telemetry/semantic-conventions/pull/2083 + +const ( + // Core MCP Attributes + AttrMCPMethodName = "mcp.method.name" + AttrMCPRequestID = "mcp.request.id" + AttrMCPSessionID = "mcp.session.id" + AttrMCPTransport = "mcp.transport" + AttrNetworkTransport = "network.transport" + AttrNetworkProtocolVer = "network.protocol.version" + + // Tool-specific Attributes + AttrMCPToolName = "mcp.tool.name" + AttrMCPToolResultIsError = "mcp.tool.result.is_error" + AttrMCPToolResultContentCount = "mcp.tool.result.content_count" + AttrMCPToolResultContent = "mcp.tool.result.content" + + // Request Arguments Prefix + AttrMCPRequestArgumentPrefix = "mcp.request.argument" + + // Sentry-specific Values + OpMCPServer = "mcp.server" + OriginMCPFunction = "auto.function.mcp_server" + SourceMCPRoute = "route" + TransportStdio = "stdio" + NetworkTransportPipe = "pipe" + JSONRPCVersion = "2.0" +) + +// WithSentryTracing wraps an MCP tool handler with Sentry tracing. +// It creates spans following OpenTelemetry MCP semantic conventions and +// captures tool execution results and errors. +// +// Example usage: +// +// mcp.AddTool(server, &mcp.Tool{ +// Name: "my_tool", +// Description: "Does something useful", +// }, WithSentryTracing("my_tool", func(ctx context.Context, req *mcp.CallToolRequest, args MyToolArgs) (*mcp.CallToolResult, any, error) { +// return m.handleMyTool(ctx, req, args) +// })) +func WithSentryTracing[In, Out any](toolName string, handler mcp.ToolHandlerFor[In, Out]) mcp.ToolHandlerFor[In, Out] { + return func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { + // Create span for tool execution + span := sentry.StartSpan(ctx, OpMCPServer) + defer span.Finish() + + // Set span name following MCP conventions: "tools/call {tool_name}" + span.Description = fmt.Sprintf("tools/call %s", toolName) + + // Set common MCP attributes + span.SetData(AttrMCPMethodName, "tools/call") + span.SetData(AttrMCPToolName, toolName) + span.SetData(AttrMCPTransport, TransportStdio) + span.SetData(AttrNetworkTransport, NetworkTransportPipe) + span.SetData(AttrNetworkProtocolVer, JSONRPCVersion) + + // Set Sentry-specific attributes + span.SetData("sentry.origin", OriginMCPFunction) + span.SetData("sentry.source", SourceMCPRoute) + + // Extract and set request ID if available + if req != nil { + // The CallToolRequest may have metadata we can extract + // For now, we'll use reflection to check if there's an ID field + setRequestMetadata(span, req) + } + + // Extract and set tool arguments + setToolArguments(span, args) + + // Execute the handler with the span's context + ctx = span.Context() + result, data, err := handler(ctx, req, args) + + // Capture error if present + if err != nil { + span.Status = sentry.SpanStatusInternalError + span.SetData(AttrMCPToolResultIsError, true) + + // Capture the error to Sentry with context + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = sentry.CurrentHub() + } + hub.CaptureException(err) + } else { + span.Status = sentry.SpanStatusOK + span.SetData(AttrMCPToolResultIsError, false) + + // Extract result metadata + if result != nil { + setResultMetadata(span, result) + } + } + + return result, data, err + } +} + +// setRequestMetadata extracts metadata from the CallToolRequest +func setRequestMetadata(span *sentry.Span, req *mcp.CallToolRequest) { + // Use reflection to safely check for an ID field + val := reflect.ValueOf(req) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + // Try to find common ID/request ID fields + if val.Kind() == reflect.Struct { + // Check for ID field + if idField := val.FieldByName("ID"); idField.IsValid() { + switch idField.Kind() { + case reflect.String: + if id := idField.String(); id != "" { + span.SetData(AttrMCPRequestID, id) + } + case reflect.Int, reflect.Int64: + if id := idField.Int(); id != 0 { + span.SetData(AttrMCPRequestID, fmt.Sprintf("%d", id)) + } + } + } + + // Check for SessionID field + if sessionField := val.FieldByName("SessionID"); sessionField.IsValid() && sessionField.Kind() == reflect.String { + if sessionID := sessionField.String(); sessionID != "" { + span.SetData(AttrMCPSessionID, sessionID) + } + } + } +} + +// setToolArguments extracts tool arguments and sets them as span attributes +func setToolArguments(span *sentry.Span, args any) { + if args == nil { + return + } + + val := reflect.ValueOf(args) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + return + } + + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // Skip unexported fields + if !fieldType.IsExported() { + continue + } + + // Get JSON tag name or use field name + jsonTag := fieldType.Tag.Get("json") + fieldName := fieldType.Name + if jsonTag != "" { + // Split on comma to handle tags like "json:field,omitempty" + parts := strings.Split(jsonTag, ",") + if parts[0] != "" && parts[0] != "-" { + fieldName = parts[0] + } + } + + // Convert field name to lowercase for attribute + attrKey := fmt.Sprintf("%s.%s", AttrMCPRequestArgumentPrefix, strings.ToLower(fieldName)) + + // Set the value based on type + switch field.Kind() { + case reflect.String: + if value := field.String(); value != "" { + span.SetData(attrKey, value) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + span.SetData(attrKey, field.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + span.SetData(attrKey, field.Uint()) + case reflect.Float32, reflect.Float64: + span.SetData(attrKey, field.Float()) + case reflect.Bool: + span.SetData(attrKey, field.Bool()) + default: + // For complex types, serialize to JSON + if field.CanInterface() { + if jsonBytes, err := json.Marshal(field.Interface()); err == nil { + span.SetData(attrKey, string(jsonBytes)) + } + } + } + } +} + +// setResultMetadata extracts result metadata and sets span attributes +func setResultMetadata(span *sentry.Span, result *mcp.CallToolResult) { + if result == nil { + return + } + + // Count content items + contentCount := len(result.Content) + span.SetData(AttrMCPToolResultContentCount, contentCount) + + // If there's content, serialize it for the span + // Note: We only capture metadata about the content, not the full content + // to avoid potentially large payloads + if contentCount > 0 { + contentTypes := make([]string, 0, contentCount) + for _, content := range result.Content { + // Extract content type information + if content != nil { + contentTypes = append(contentTypes, getContentType(content)) + } + } + + if len(contentTypes) > 0 { + // Store content types as JSON array string + if typesJSON, err := json.Marshal(contentTypes); err == nil { + span.SetData(AttrMCPToolResultContent, string(typesJSON)) + } + } + } +} + +// getContentType returns the type of content +func getContentType(content mcp.Content) string { + switch c := content.(type) { + case *mcp.TextContent: + return "text" + case *mcp.ImageContent: + return "image" + case *mcp.AudioContent: + return "audio" + case *mcp.ResourceLink: + return "resource_link" + case *mcp.EmbeddedResource: + return "embedded_resource" + default: + return fmt.Sprintf("%T", c) + } +} diff --git a/internal/cli/mcp/sentry_test.go b/internal/cli/mcp/sentry_test.go new file mode 100644 index 0000000..985779f --- /dev/null +++ b/internal/cli/mcp/sentry_test.go @@ -0,0 +1,200 @@ +package mcp + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/getsentry/sentry-go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// MockArgs represents test arguments for a tool +type MockArgs struct { + Name string `json:"name" jsonschema:"The name parameter"` + Count int `json:"count" jsonschema:"The count parameter"` +} + +func TestWithSentryTracing_Success(t *testing.T) { + // Initialize Sentry with a test transport + transport := &testTransport{} + err := sentry.Init(sentry.ClientOptions{ + Dsn: "https://test@test.ingest.sentry.io/123456", + Transport: transport, + }) + if err != nil { + t.Fatalf("Failed to initialize Sentry: %v", err) + } + defer sentry.Flush(2 * time.Second) + + // Create a mock handler that succeeds + mockHandler := func(ctx context.Context, req *mcp.CallToolRequest, args MockArgs) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Success"}, + }, + }, map[string]string{"status": "ok"}, nil + } + + // Wrap with Sentry tracing + wrappedHandler := WithSentryTracing("test_tool", mockHandler) + + // Execute the handler + ctx := context.Background() + args := MockArgs{Name: "test", Count: 42} + result, data, err := wrappedHandler(ctx, &mcp.CallToolRequest{}, args) + + // Verify execution + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result == nil { + t.Error("Expected result, got nil") + } + if data == nil { + t.Error("Expected data, got nil") + } + + // Flush to ensure span is sent + sentry.Flush(2 * time.Second) + + // Note: In a real test, you would verify the span was created with correct attributes + // This requires either mocking the Sentry transport or using the test transport +} + +func TestWithSentryTracing_Error(t *testing.T) { + // Initialize Sentry with a test transport + transport := &testTransport{} + err := sentry.Init(sentry.ClientOptions{ + Dsn: "https://test@test.ingest.sentry.io/123456", + Transport: transport, + }) + if err != nil { + t.Fatalf("Failed to initialize Sentry: %v", err) + } + defer sentry.Flush(2 * time.Second) + + // Create a mock handler that fails + expectedErr := errors.New("tool execution failed") + mockHandler := func(ctx context.Context, req *mcp.CallToolRequest, args MockArgs) (*mcp.CallToolResult, any, error) { + return nil, nil, expectedErr + } + + // Wrap with Sentry tracing + wrappedHandler := WithSentryTracing("test_tool_error", mockHandler) + + // Execute the handler + ctx := context.Background() + args := MockArgs{Name: "test", Count: 42} + result, data, err := wrappedHandler(ctx, &mcp.CallToolRequest{}, args) + + // Verify error is propagated + if err == nil { + t.Error("Expected error, got nil") + } + if err != expectedErr { + t.Errorf("Expected error %v, got %v", expectedErr, err) + } + if result != nil { + t.Errorf("Expected nil result on error, got: %v", result) + } + if data != nil { + t.Errorf("Expected nil data on error, got: %v", data) + } + + // Flush to ensure error is sent + sentry.Flush(2 * time.Second) +} + +func TestWithSentryTracing_ArgumentExtraction(t *testing.T) { + // Initialize Sentry + transport := &testTransport{} + err := sentry.Init(sentry.ClientOptions{ + Dsn: "https://test@test.ingest.sentry.io/123456", + Transport: transport, + }) + if err != nil { + t.Fatalf("Failed to initialize Sentry: %v", err) + } + defer sentry.Flush(2 * time.Second) + + // Create a handler that just returns success + mockHandler := func(ctx context.Context, req *mcp.CallToolRequest, args MockArgs) (*mcp.CallToolResult, any, error) { + // Verify arguments were passed correctly + if args.Name != "test_arg" { + return nil, nil, errors.New("wrong name argument") + } + if args.Count != 123 { + return nil, nil, errors.New("wrong count argument") + } + return &mcp.CallToolResult{}, nil, nil + } + + // Wrap with Sentry tracing + wrappedHandler := WithSentryTracing("test_args", mockHandler) + + // Execute with specific arguments + ctx := context.Background() + args := MockArgs{Name: "test_arg", Count: 123} + _, _, err = wrappedHandler(ctx, &mcp.CallToolRequest{}, args) + + if err != nil { + t.Errorf("Handler failed: %v", err) + } + + // The span should have attributes: + // - mcp.request.argument.name = "test_arg" + // - mcp.request.argument.count = 123 + sentry.Flush(2 * time.Second) +} + +func TestGetContentType(t *testing.T) { + tests := []struct { + name string + content mcp.Content + expected string + }{ + { + name: "TextContent", + content: &mcp.TextContent{Text: "test"}, + expected: "text", + }, + { + name: "ImageContent", + content: &mcp.ImageContent{Data: []byte("base64data"), MIMEType: "image/png"}, + expected: "image", + }, + { + name: "AudioContent", + content: &mcp.AudioContent{Data: []byte("base64data"), MIMEType: "audio/mp3"}, + expected: "audio", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getContentType(tt.content) + if result != tt.expected { + t.Errorf("Expected content type %q, got %q", tt.expected, result) + } + }) + } +} + +// testTransport is a no-op transport for testing +type testTransport struct{} + +func (t *testTransport) Configure(options sentry.ClientOptions) {} + +func (t *testTransport) SendEvent(event *sentry.Event) {} + +func (t *testTransport) Flush(timeout time.Duration) bool { + return true +} + +func (t *testTransport) FlushWithContext(ctx context.Context) bool { + return true +} + +func (t *testTransport) Close() {} diff --git a/internal/cli/mcp/server.go b/internal/cli/mcp/server.go index 29da851..c1ed0e4 100644 --- a/internal/cli/mcp/server.go +++ b/internal/cli/mcp/server.go @@ -1,7 +1,6 @@ package mcp import ( - "context" "log/slog" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -28,11 +27,9 @@ func NewMCPServer(actionsService *github.ActionsService, logger *slog.Logger) *M // RegisterTools registers all available tools with the MCP server. func (m *MCPServer) RegisterTools(server *mcp.Server) { - // Register get_action_parameters tool + // Register get_action_parameters tool with Sentry tracing mcp.AddTool(server, &mcp.Tool{ Name: "get_action_parameters", Description: "Fetch and parse a GitHub Action's action.yml file. Returns the complete action.yml structure including inputs, outputs, runs configuration, and metadata.", - }, func(ctx context.Context, req *mcp.CallToolRequest, args GetActionParametersArgs) (*mcp.CallToolResult, any, error) { - return m.handleGetActionParameters(ctx, req, args) - }) + }, WithSentryTracing("get_action_parameters", m.handleGetActionParameters)) }