Skip to content

Commit a4b0e32

Browse files
fix comment
1 parent 360521f commit a4b0e32

9 files changed

Lines changed: 694 additions & 2 deletions

File tree

pkg/appolly/app/request/span.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,15 @@ const (
9797
HTTPSubtypeGemini = 8 // http + Google AI Studio (Gemini)
9898
HTTPSubtypeJSONRPC = 9 // http + JSON-RPC
9999
HTTPSubtypeAWSBedrock = 10 // http + AWS Bedrock
100+
HTTPSubtypeMCP = 11 // http + Model Context Protocol
100101
)
101102

102103
func IsGenAISubtype(subtype int) bool {
103104
return subtype == HTTPSubtypeOpenAI ||
104105
subtype == HTTPSubtypeAnthropic ||
105106
subtype == HTTPSubtypeGemini ||
106-
subtype == HTTPSubtypeAWSBedrock
107+
subtype == HTTPSubtypeAWSBedrock ||
108+
subtype == HTTPSubtypeMCP
107109
}
108110

109111
//nolint:cyclop
@@ -253,6 +255,7 @@ type GenAI struct {
253255
Anthropic *VendorAnthropic
254256
Gemini *VendorGemini
255257
Bedrock *VendorBedrock
258+
MCP *MCPCall
256259
}
257260

258261
type OpenAIUsage struct {
@@ -609,6 +612,28 @@ func (b *VendorBedrock) GetStopReason() string {
609612
return ""
610613
}
611614

615+
// MCPCall holds parsed data from a Model Context Protocol request/response.
616+
type MCPCall struct {
617+
Method string `json:"method"` // mcp.method.name
618+
ToolName string `json:"toolName,omitempty"` // gen_ai.tool.name (tools/call)
619+
ResourceURI string `json:"resourceUri,omitempty"` // mcp.resource.uri (resources/read)
620+
PromptName string `json:"promptName,omitempty"` // gen_ai.prompt.name (prompts/get)
621+
SessionID string `json:"sessionId,omitempty"` // mcp.session.id
622+
ProtocolVer string `json:"protocolVer,omitempty"` // mcp.protocol.version
623+
RequestID string `json:"requestId,omitempty"` // jsonrpc.request.id
624+
ErrorCode int `json:"errorCode,omitempty"` // JSON-RPC error code
625+
ErrorMessage string `json:"errorMessage,omitempty"` // JSON-RPC error message
626+
}
627+
628+
// OperationName returns the GenAI operation name for the MCP method.
629+
// tools/call maps to execute_tool; other methods return the method name as-is.
630+
func (m *MCPCall) OperationName() string {
631+
if m.Method == "tools/call" {
632+
return "execute_tool"
633+
}
634+
return m.Method
635+
}
636+
612637
type JSONRPC struct {
613638
Method string `json:"method"`
614639
Version string `json:"version"`
@@ -1254,6 +1279,14 @@ func (s *Span) TraceName() string {
12541279
return "invoke_model"
12551280
}
12561281

1282+
if s.SubType == HTTPSubtypeMCP && s.GenAI != nil && s.GenAI.MCP != nil {
1283+
op := s.GenAI.MCP.OperationName()
1284+
if s.GenAI.MCP.ToolName != "" {
1285+
return op + " " + s.GenAI.MCP.ToolName
1286+
}
1287+
return op
1288+
}
1289+
12571290
if s.SubType == HTTPSubtypeJSONRPC && s.JSONRPC != nil {
12581291
if s.JSONRPC.Method != "" {
12591292
return s.JSONRPC.Method
@@ -1537,6 +1570,9 @@ func (s *Span) GenAIOperationName() string {
15371570
if s.GenAI.Bedrock != nil {
15381571
return "invoke_model"
15391572
}
1573+
if s.GenAI.MCP != nil {
1574+
return s.GenAI.MCP.OperationName()
1575+
}
15401576
return ""
15411577
}
15421578

pkg/config/payload_extraction.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,14 @@ type GenAIConfig struct {
7777
Gemini GeminiConfig `yaml:"gemini"`
7878
// AWS Bedrock payload extraction and parsing
7979
Bedrock BedrockConfig `yaml:"bedrock"`
80+
// Model Context Protocol (MCP) payload extraction and parsing
81+
MCP MCPConfig `yaml:"mcp"`
8082
}
8183

8284
func (g *GenAIConfig) Enabled() bool {
8385
return g.Anthropic.Enabled || g.OpenAI.Enabled ||
84-
g.Gemini.Enabled || g.Bedrock.Enabled
86+
g.Gemini.Enabled || g.Bedrock.Enabled ||
87+
g.MCP.Enabled
8588
}
8689

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

110+
type MCPConfig struct {
111+
// Enable Model Context Protocol (MCP) payload extraction and parsing
112+
Enabled bool `yaml:"enabled" env:"OTEL_EBPF_HTTP_MCP_ENABLED" validate:"boolean"`
113+
}
114+
107115
type JSONRPCConfig struct {
108116
// Enable JSON-RPC payload extraction and parsing
109117
Enabled bool `yaml:"enabled" env:"OTEL_EBPF_HTTP_JSONRPC_ENABLED" validate:"boolean"`

pkg/config/payload_extraction_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,23 @@ func TestEnrichmentConfig_Validate_EmptyRules(t *testing.T) {
225225
cfg := EnrichmentConfig{}
226226
assert.NoError(t, cfg.Validate())
227227
}
228+
229+
func TestGenAIConfig_Enabled(t *testing.T) {
230+
tests := []struct {
231+
name string
232+
cfg GenAIConfig
233+
enabled bool
234+
}{
235+
{name: "all disabled", cfg: GenAIConfig{}, enabled: false},
236+
{name: "openai", cfg: GenAIConfig{OpenAI: OpenAIConfig{Enabled: true}}, enabled: true},
237+
{name: "anthropic", cfg: GenAIConfig{Anthropic: AnthropicConfig{Enabled: true}}, enabled: true},
238+
{name: "gemini", cfg: GenAIConfig{Gemini: GeminiConfig{Enabled: true}}, enabled: true},
239+
{name: "bedrock", cfg: GenAIConfig{Bedrock: BedrockConfig{Enabled: true}}, enabled: true},
240+
{name: "mcp", cfg: GenAIConfig{MCP: MCPConfig{Enabled: true}}, enabled: true},
241+
}
242+
for _, tt := range tests {
243+
t.Run(tt.name, func(t *testing.T) {
244+
assert.Equal(t, tt.enabled, tt.cfg.Enabled())
245+
})
246+
}
247+
}

pkg/ebpf/common/http/mcp.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package ebpfcommon // import "go.opentelemetry.io/obi/pkg/ebpf/common/http"
5+
6+
import (
7+
"bytes"
8+
"encoding/json"
9+
"io"
10+
"log/slog"
11+
"net/http"
12+
13+
"go.opentelemetry.io/obi/pkg/appolly/app/request"
14+
)
15+
16+
// mcpMethods enumerates known MCP JSON-RPC method names.
17+
var mcpMethods = map[string]bool{
18+
"initialize": true,
19+
"notifications/initialized": true,
20+
"tools/call": true,
21+
"tools/list": true,
22+
"resources/read": true,
23+
"resources/list": true,
24+
"resources/subscribe": true,
25+
"resources/unsubscribe": true,
26+
"resources/templates/list": true,
27+
"prompts/get": true,
28+
"prompts/list": true,
29+
"completion/complete": true,
30+
"logging/setLevel": true,
31+
"notifications/cancelled": true,
32+
"notifications/resources/updated": true,
33+
"notifications/tools/list_changed": true,
34+
"notifications/prompts/list_changed": true,
35+
"ping": true,
36+
}
37+
38+
// mcpSessionHeader is the HTTP header that carries the MCP session identifier.
39+
const mcpSessionHeader = "Mcp-Session-Id"
40+
41+
// mcpRequest is the JSON-RPC 2.0 request envelope used by MCP.
42+
type mcpRequest struct {
43+
JSONRPC string `json:"jsonrpc"`
44+
Method string `json:"method"`
45+
ID json.RawMessage `json:"id,omitempty"`
46+
Params json.RawMessage `json:"params,omitempty"`
47+
}
48+
49+
// mcpResponse is the JSON-RPC 2.0 response envelope used by MCP.
50+
type mcpResponse struct {
51+
JSONRPC string `json:"jsonrpc"`
52+
ID json.RawMessage `json:"id,omitempty"`
53+
Result json.RawMessage `json:"result,omitempty"`
54+
Error *mcpError `json:"error,omitempty"`
55+
}
56+
57+
type mcpError struct {
58+
Code int `json:"code"`
59+
Message string `json:"message"`
60+
}
61+
62+
// Param structures for extracting method-specific fields.
63+
64+
type mcpToolCallParams struct {
65+
Name string `json:"name"`
66+
}
67+
68+
type mcpResourceParams struct {
69+
URI string `json:"uri"`
70+
}
71+
72+
type mcpPromptParams struct {
73+
Name string `json:"name"`
74+
}
75+
76+
type mcpInitializeParams struct {
77+
ProtocolVersion string `json:"protocolVersion"`
78+
}
79+
80+
type mcpInitializeResult struct {
81+
ProtocolVersion string `json:"protocolVersion"`
82+
}
83+
84+
// MCPSpan detects and parses an MCP JSON-RPC request/response pair.
85+
// It returns the enriched span and true when the request is a valid MCP call,
86+
// or the original span and false otherwise.
87+
func MCPSpan(baseSpan *request.Span, req *http.Request, resp *http.Response) (request.Span, bool) {
88+
if req.Method != http.MethodPost {
89+
return *baseSpan, false
90+
}
91+
92+
sessionID := req.Header.Get(mcpSessionHeader)
93+
94+
reqB, err := io.ReadAll(req.Body)
95+
if err != nil {
96+
return *baseSpan, false
97+
}
98+
req.Body = io.NopCloser(bytes.NewBuffer(reqB))
99+
100+
reqB = bytes.TrimSpace(reqB)
101+
if len(reqB) == 0 || reqB[0] != '{' {
102+
return *baseSpan, false
103+
}
104+
105+
var rpcReq mcpRequest
106+
if err := json.Unmarshal(reqB, &rpcReq); err != nil {
107+
return *baseSpan, false
108+
}
109+
110+
if !mcpMethods[rpcReq.Method] {
111+
// Not a recognized MCP method. Check whether the session header
112+
// was present — that still qualifies the request as MCP even if
113+
// the method is unknown (e.g. a custom extension method).
114+
if sessionID == "" {
115+
return *baseSpan, false
116+
}
117+
}
118+
119+
slog.Debug("MCP", "method", rpcReq.Method, "request", string(reqB))
120+
121+
result := &request.MCPCall{
122+
Method: rpcReq.Method,
123+
SessionID: sessionID,
124+
}
125+
126+
if len(rpcReq.ID) > 0 && string(rpcReq.ID) != "null" {
127+
result.RequestID = rawIDString(rpcReq.ID)
128+
}
129+
130+
parseMCPParams(rpcReq, result)
131+
132+
// Parse response for error and protocol version.
133+
if resp != nil && resp.Body != nil {
134+
respB, err := getResponseBody(resp)
135+
if err == nil && len(respB) > 0 {
136+
parseMCPResponse(respB, result)
137+
}
138+
}
139+
140+
baseSpan.SubType = request.HTTPSubtypeMCP
141+
baseSpan.GenAI = &request.GenAI{
142+
MCP: result,
143+
}
144+
145+
return *baseSpan, true
146+
}
147+
148+
// parseMCPParams extracts method-specific fields from the request params.
149+
func parseMCPParams(rpcReq mcpRequest, result *request.MCPCall) {
150+
if len(rpcReq.Params) == 0 {
151+
return
152+
}
153+
154+
switch rpcReq.Method {
155+
case "tools/call":
156+
var p mcpToolCallParams
157+
if json.Unmarshal(rpcReq.Params, &p) == nil {
158+
result.ToolName = p.Name
159+
}
160+
case "resources/read", "resources/subscribe", "resources/unsubscribe":
161+
var p mcpResourceParams
162+
if json.Unmarshal(rpcReq.Params, &p) == nil {
163+
result.ResourceURI = p.URI
164+
}
165+
case "prompts/get":
166+
var p mcpPromptParams
167+
if json.Unmarshal(rpcReq.Params, &p) == nil {
168+
result.PromptName = p.Name
169+
}
170+
case "initialize":
171+
var p mcpInitializeParams
172+
if json.Unmarshal(rpcReq.Params, &p) == nil {
173+
result.ProtocolVer = p.ProtocolVersion
174+
}
175+
}
176+
}
177+
178+
// parseMCPResponse extracts error information and protocol version from the response.
179+
func parseMCPResponse(data []byte, result *request.MCPCall) {
180+
data = bytes.TrimSpace(data)
181+
if len(data) == 0 || data[0] != '{' {
182+
return
183+
}
184+
185+
var resp mcpResponse
186+
if err := json.Unmarshal(data, &resp); err != nil {
187+
return
188+
}
189+
190+
if resp.Error != nil {
191+
result.ErrorCode = resp.Error.Code
192+
result.ErrorMessage = resp.Error.Message
193+
}
194+
195+
// For initialize responses, extract the negotiated protocol version.
196+
if result.Method == "initialize" && len(resp.Result) > 0 {
197+
var initResult mcpInitializeResult
198+
if json.Unmarshal(resp.Result, &initResult) == nil && initResult.ProtocolVersion != "" {
199+
result.ProtocolVer = initResult.ProtocolVersion
200+
}
201+
}
202+
}

0 commit comments

Comments
 (0)