Skip to content
14 changes: 14 additions & 0 deletions docs/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,10 @@
"$ref": "#/$defs/GeminiConfig",
"description": "Google AI Studio (Gemini) payload extraction and parsing"
},
"mcp": {
"$ref": "#/$defs/MCPConfig",
"description": "Model Context Protocol (MCP) payload extraction and parsing"
},
"openai": {
"$ref": "#/$defs/OpenAIConfig",
"description": "OpenAI payload extraction and parsing"
Expand Down Expand Up @@ -1245,6 +1249,16 @@
},
"type": "object"
},
"MCPConfig": {
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable Model Context Protocol (MCP) payload extraction and parsing",
"x-env-var": "OTEL_EBPF_HTTP_MCP_ENABLED"
}
},
"type": "object"
},
"MapsConfig": {
"properties": {
"global_scale_factor": {
Expand Down
49 changes: 48 additions & 1 deletion pkg/appolly/app/request/span.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,15 @@ const (
HTTPSubtypeGemini = 8 // http + Google AI Studio (Gemini)
HTTPSubtypeJSONRPC = 9 // http + JSON-RPC
HTTPSubtypeAWSBedrock = 10 // http + AWS Bedrock
HTTPSubtypeMCP = 11 // http + Model Context Protocol
)

func IsGenAISubtype(subtype int) bool {
return subtype == HTTPSubtypeOpenAI ||
subtype == HTTPSubtypeAnthropic ||
subtype == HTTPSubtypeGemini ||
subtype == HTTPSubtypeAWSBedrock
subtype == HTTPSubtypeAWSBedrock ||
subtype == HTTPSubtypeMCP
}

//nolint:cyclop
Expand Down Expand Up @@ -253,6 +255,7 @@ type GenAI struct {
Anthropic *VendorAnthropic
Gemini *VendorGemini
Bedrock *VendorBedrock
MCP *MCPCall
}

type OpenAIUsage struct {
Expand Down Expand Up @@ -609,6 +612,28 @@ func (b *VendorBedrock) GetStopReason() string {
return ""
}

// MCPCall holds parsed data from a Model Context Protocol request/response.
type MCPCall struct {
Method string `json:"method"` // mcp.method.name
ToolName string `json:"toolName,omitempty"` // gen_ai.tool.name (tools/call)
ResourceURI string `json:"resourceUri,omitempty"` // mcp.resource.uri (resources/read)
PromptName string `json:"promptName,omitempty"` // gen_ai.prompt.name (prompts/get)
SessionID string `json:"sessionId,omitempty"` // mcp.session.id
ProtocolVer string `json:"protocolVer,omitempty"` // mcp.protocol.version
RequestID string `json:"requestId,omitempty"` // jsonrpc.request.id
ErrorCode int `json:"errorCode,omitempty"` // JSON-RPC error code
ErrorMessage string `json:"errorMessage,omitempty"` // JSON-RPC error message
}

// OperationName returns the GenAI operation name for the MCP method.
// tools/call maps to execute_tool; other methods return the method name as-is.
func (m *MCPCall) OperationName() string {
if m.Method == "tools/call" {
return "execute_tool"
}
return m.Method
}

type JSONRPC struct {
Method string `json:"method"`
Version string `json:"version"`
Expand Down Expand Up @@ -1017,10 +1042,16 @@ func SpanStatusMessage(span *Span) string {
if span.SubType == HTTPSubtypeJSONRPC && span.JSONRPC != nil && span.JSONRPC.ErrorMessage != "" {
return span.JSONRPC.ErrorMessage
}
if span.SubType == HTTPSubtypeMCP && span.GenAI != nil && span.GenAI.MCP != nil && span.GenAI.MCP.ErrorMessage != "" {
return span.GenAI.MCP.ErrorMessage
}
case EventTypeHTTP:
if span.SubType == HTTPSubtypeJSONRPC && span.JSONRPC != nil && span.JSONRPC.ErrorMessage != "" {
return span.JSONRPC.ErrorMessage
}
if span.SubType == HTTPSubtypeMCP && span.GenAI != nil && span.GenAI.MCP != nil && span.GenAI.MCP.ErrorMessage != "" {
return span.GenAI.MCP.ErrorMessage
}
}
return ""
}
Expand All @@ -1036,6 +1067,11 @@ func HTTPSpanStatusCode(span *Span) string {
return StatusCodeError
}

// MCP errors are signaled in the JSON-RPC response body.
if span.SubType == HTTPSubtypeMCP && span.GenAI != nil && span.GenAI.MCP != nil && span.GenAI.MCP.ErrorCode != 0 {
return StatusCodeError
}

if span.Type == EventTypeHTTPClient {
if span.Status < 400 {
// this is possibly not needed, because in my experiments they
Expand Down Expand Up @@ -1254,6 +1290,14 @@ func (s *Span) TraceName() string {
return "invoke_model"
}

if s.SubType == HTTPSubtypeMCP && s.GenAI != nil && s.GenAI.MCP != nil {
op := s.GenAI.MCP.OperationName()
if s.GenAI.MCP.ToolName != "" {
return op + " " + s.GenAI.MCP.ToolName
}
return op
}

if s.SubType == HTTPSubtypeJSONRPC && s.JSONRPC != nil {
if s.JSONRPC.Method != "" {
return s.JSONRPC.Method
Expand Down Expand Up @@ -1537,6 +1581,9 @@ func (s *Span) GenAIOperationName() string {
if s.GenAI.Bedrock != nil {
return "invoke_model"
}
if s.GenAI.MCP != nil {
return s.GenAI.MCP.OperationName()
}
return ""
}

Expand Down
100 changes: 100 additions & 0 deletions pkg/appolly/app/request/span_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,106 @@ func TestSpanStatusMessage_JSONRPC(t *testing.T) {
}
}

func TestSpanStatusCode_MCP(t *testing.T) {
tests := []struct {
name string
span *Span
expectedCode string
}{
{
name: "server span with MCP error",
span: &Span{
Type: EventTypeHTTP,
Status: 200,
SubType: HTTPSubtypeMCP,
GenAI: &GenAI{MCP: &MCPCall{Method: "tools/call", ErrorCode: -32602, ErrorMessage: "Unknown tool"}},
},
expectedCode: StatusCodeError,
},
{
name: "server span without MCP error",
span: &Span{
Type: EventTypeHTTP,
Status: 200,
SubType: HTTPSubtypeMCP,
GenAI: &GenAI{MCP: &MCPCall{Method: "tools/call"}},
},
expectedCode: StatusCodeUnset,
},
{
name: "client span with MCP error",
span: &Span{
Type: EventTypeHTTPClient,
Status: 200,
SubType: HTTPSubtypeMCP,
GenAI: &GenAI{MCP: &MCPCall{Method: "tools/call", ErrorCode: -32600, ErrorMessage: "Invalid Request"}},
},
expectedCode: StatusCodeError,
},
{
name: "client span without MCP error",
span: &Span{
Type: EventTypeHTTPClient,
Status: 200,
SubType: HTTPSubtypeMCP,
GenAI: &GenAI{MCP: &MCPCall{Method: "tools/call"}},
},
expectedCode: StatusCodeUnset,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expectedCode, SpanStatusCode(tt.span))
})
}
}

func TestSpanStatusMessage_MCP(t *testing.T) {
tests := []struct {
name string
span *Span
expectedMessage string
}{
{
name: "server span with MCP error message",
span: &Span{
Type: EventTypeHTTP,
Status: 200,
SubType: HTTPSubtypeMCP,
GenAI: &GenAI{MCP: &MCPCall{Method: "tools/call", ErrorCode: -32602, ErrorMessage: "Unknown tool"}},
},
expectedMessage: "Unknown tool",
},
{
name: "client span with MCP error message",
span: &Span{
Type: EventTypeHTTPClient,
Status: 200,
SubType: HTTPSubtypeMCP,
GenAI: &GenAI{MCP: &MCPCall{Method: "tools/call", ErrorCode: -32600, ErrorMessage: "Invalid Request"}},
},
expectedMessage: "Invalid Request",
},
{
name: "server span without MCP error",
span: &Span{
Type: EventTypeHTTP,
Status: 200,
SubType: HTTPSubtypeMCP,
GenAI: &GenAI{MCP: &MCPCall{Method: "tools/call"}},
},
expectedMessage: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expectedMessage, SpanStatusMessage(tt.span))
})
}
}

type jsonObject = map[string]any

func deserializeJSONObject(data []byte) (jsonObject, error) {
Expand Down
10 changes: 9 additions & 1 deletion pkg/config/payload_extraction.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ type GenAIConfig struct {
Gemini GeminiConfig `yaml:"gemini"`
// AWS Bedrock payload extraction and parsing
Bedrock BedrockConfig `yaml:"bedrock"`
// Model Context Protocol (MCP) payload extraction and parsing
MCP MCPConfig `yaml:"mcp"`
}

func (g *GenAIConfig) Enabled() bool {
return g.Anthropic.Enabled || g.OpenAI.Enabled ||
g.Gemini.Enabled || g.Bedrock.Enabled
g.Gemini.Enabled || g.Bedrock.Enabled ||
g.MCP.Enabled
}

type OpenAIConfig struct {
Expand All @@ -104,6 +107,11 @@ type BedrockConfig struct {
Enabled bool `yaml:"enabled" env:"OTEL_EBPF_HTTP_BEDROCK_ENABLED" validate:"boolean"`
}

type MCPConfig struct {
// Enable Model Context Protocol (MCP) payload extraction and parsing
Enabled bool `yaml:"enabled" env:"OTEL_EBPF_HTTP_MCP_ENABLED" validate:"boolean"`
}

type JSONRPCConfig struct {
// Enable JSON-RPC payload extraction and parsing
Enabled bool `yaml:"enabled" env:"OTEL_EBPF_HTTP_JSONRPC_ENABLED" validate:"boolean"`
Expand Down
20 changes: 20 additions & 0 deletions pkg/config/payload_extraction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,23 @@ func TestEnrichmentConfig_Validate_EmptyRules(t *testing.T) {
cfg := EnrichmentConfig{}
assert.NoError(t, cfg.Validate())
}

func TestGenAIConfig_Enabled(t *testing.T) {
tests := []struct {
name string
cfg GenAIConfig
enabled bool
}{
{name: "all disabled", cfg: GenAIConfig{}, enabled: false},
{name: "openai", cfg: GenAIConfig{OpenAI: OpenAIConfig{Enabled: true}}, enabled: true},
{name: "anthropic", cfg: GenAIConfig{Anthropic: AnthropicConfig{Enabled: true}}, enabled: true},
{name: "gemini", cfg: GenAIConfig{Gemini: GeminiConfig{Enabled: true}}, enabled: true},
{name: "bedrock", cfg: GenAIConfig{Bedrock: BedrockConfig{Enabled: true}}, enabled: true},
{name: "mcp", cfg: GenAIConfig{MCP: MCPConfig{Enabled: true}}, enabled: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.enabled, tt.cfg.Enabled())
})
}
}
Loading