Skip to content

Commit c3e3648

Browse files
committed
add intent annotation to llmobs module
1 parent ae93ce3 commit c3e3648

File tree

9 files changed

+266
-8
lines changed

9 files changed

+266
-8
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2025 Datadog, Inc.
5+
6+
package mcpgo
7+
8+
import (
9+
"context"
10+
"slices"
11+
12+
"github.com/DataDog/dd-trace-go/v2/llmobs"
13+
"github.com/mark3labs/mcp-go/mcp"
14+
"github.com/mark3labs/mcp-go/server"
15+
)
16+
17+
const ddtraceKey = "ddtrace"
18+
19+
const intentPrompt string = "Briefly describe the wider context task, and why this tool was chosen. Omit argument values, PII/secrets. Use English."
20+
21+
func ddtraceSchema() map[string]any {
22+
return map[string]any{
23+
"type": "object",
24+
"properties": map[string]any{
25+
"intent": map[string]any{
26+
"type": "string",
27+
"description": intentPrompt,
28+
},
29+
},
30+
"required": []string{"intent"},
31+
"additionalProperties": false,
32+
}
33+
}
34+
35+
// Injects tracing parameters into the tool list response by mutating it.
36+
func injectDdtraceListToolsHook(ctx context.Context, id any, message *mcp.ListToolsRequest, result *mcp.ListToolsResult) {
37+
if result == nil || result.Tools == nil {
38+
return
39+
}
40+
41+
for i := range result.Tools {
42+
t := &result.Tools[i]
43+
44+
if t.RawInputSchema != nil {
45+
instr.Logger().Warn("mcp-go intent capture: raw input schema not supported")
46+
continue
47+
}
48+
49+
if t.InputSchema.Type == "" {
50+
t.InputSchema.Type = "object"
51+
}
52+
if t.InputSchema.Properties == nil {
53+
t.InputSchema.Properties = map[string]any{}
54+
}
55+
56+
// Insert/overwrite the ddtrace property
57+
t.InputSchema.Properties[ddtraceKey] = ddtraceSchema()
58+
59+
// Mark ddtrace as required (idempotent)
60+
if !slices.Contains(t.InputSchema.Required, ddtraceKey) {
61+
t.InputSchema.Required = append(t.InputSchema.Required, ddtraceKey)
62+
}
63+
}
64+
}
65+
66+
// Removing tracing parameters from the tool call request so its not sent to the tool.
67+
// This must be registered before the tool handler middleware, so that the span is available.
68+
// This must be registered after any user-defined middleware so that it is not visible to them.
69+
var processAndRemoveDdtraceToolMiddleware = func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
70+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
71+
if m, ok := request.Params.Arguments.(map[string]any); ok && m != nil {
72+
if ddtraceVal, has := m[ddtraceKey]; has {
73+
if ddtraceMap, ok := ddtraceVal.(map[string]any); ok {
74+
processDdtrace(ctx, ddtraceMap)
75+
} else if instr != nil && instr.Logger() != nil {
76+
instr.Logger().Warn("mcp-go intent capture: ddtrace value is not a map")
77+
}
78+
delete(m, ddtraceKey)
79+
request.Params.Arguments = m
80+
}
81+
}
82+
83+
return next(ctx, request)
84+
}
85+
}
86+
87+
func processDdtrace(ctx context.Context, m map[string]any) {
88+
if m == nil {
89+
return
90+
}
91+
92+
intentVal, exists := m["intent"]
93+
if !exists {
94+
return
95+
}
96+
97+
intent, ok := intentVal.(string)
98+
if !ok || intent == "" {
99+
return
100+
}
101+
102+
span, ok := llmobs.SpanFromContext(ctx)
103+
if !ok {
104+
return
105+
}
106+
107+
toolSpan, ok := span.AsTool()
108+
if !ok {
109+
return
110+
}
111+
// TODO: Add fields to toolSpan to annotate intent
112+
_ = toolSpan
113+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2025 Datadog, Inc.
5+
6+
package mcpgo
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"testing"
12+
13+
"github.com/DataDog/dd-trace-go/v2/ddtrace/mocktracer"
14+
"github.com/mark3labs/mcp-go/mcp"
15+
"github.com/mark3labs/mcp-go/server"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestIntentCapture(t *testing.T) {
21+
mt := mocktracer.Start()
22+
defer mt.Stop()
23+
24+
srv := server.NewMCPServer("test-server", "1.0.0", WithTracing(&TracingConfig{IntentCaptureEnabled: true}))
25+
26+
var receivedArgs map[string]any
27+
calcTool := mcp.NewTool("calculator",
28+
mcp.WithDescription("A simple calculator"),
29+
mcp.WithString("operation", mcp.Required(), mcp.Description("The operation to perform")),
30+
mcp.WithNumber("x", mcp.Required(), mcp.Description("First number")),
31+
mcp.WithNumber("y", mcp.Required(), mcp.Description("Second number")))
32+
33+
srv.AddTool(calcTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
34+
receivedArgs = request.Params.Arguments.(map[string]any)
35+
return mcp.NewToolResultText(`{"result":8}`), nil
36+
})
37+
38+
ctx := context.Background()
39+
40+
listResp := srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`))
41+
var listResult map[string]interface{}
42+
json.Unmarshal(json.RawMessage(mustMarshal(listResp)), &listResult)
43+
44+
result := listResult["result"].(map[string]interface{})
45+
tools := result["tools"].([]interface{})
46+
require.Len(t, tools, 1)
47+
48+
tool := tools[0].(map[string]interface{})
49+
schema := tool["inputSchema"].(map[string]interface{})
50+
props := schema["properties"].(map[string]interface{})
51+
52+
assert.Contains(t, props, "operation")
53+
assert.Contains(t, props, "x")
54+
assert.Contains(t, props, "y")
55+
assert.Contains(t, props, "ddtrace")
56+
57+
// Ensure ddtrace is added to schema
58+
ddtraceSchema := props["ddtrace"].(map[string]interface{})
59+
assert.Equal(t, "object", ddtraceSchema["type"])
60+
ddtraceProps := ddtraceSchema["properties"].(map[string]interface{})
61+
intentSchema := ddtraceProps["intent"].(map[string]interface{})
62+
assert.Equal(t, "string", intentSchema["type"])
63+
assert.Equal(t, intentPrompt, intentSchema["description"])
64+
65+
required := schema["required"].([]interface{})
66+
assert.Contains(t, required, "operation")
67+
assert.Contains(t, required, "x")
68+
assert.Contains(t, required, "y")
69+
70+
session := &mockSession{id: "test"}
71+
session.Initialize()
72+
ctx = srv.WithContext(ctx, session)
73+
74+
srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"calculator","arguments":{"operation":"add","x":5,"y":3,"ddtrace":{"intent":"test"}}}}`))
75+
76+
// Ensure ddtrace is removed in tool call
77+
require.NotNil(t, receivedArgs)
78+
assert.Equal(t, "add", receivedArgs["operation"])
79+
assert.Equal(t, float64(5), receivedArgs["x"])
80+
assert.Equal(t, float64(3), receivedArgs["y"])
81+
assert.NotContains(t, receivedArgs, "ddtrace")
82+
}
83+
84+
func mustMarshal(v interface{}) []byte {
85+
b, _ := json.Marshal(v)
86+
return b
87+
}

contrib/mark3labs/mcp-go/mcpgo.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2016 Datadog, Inc.
5+
6+
package mcpgo // import "github.com/DataDog/dd-trace-go/contrib/mark3labs/mcp-go/v2"
7+
8+
import (
9+
"github.com/DataDog/dd-trace-go/v2/instrumentation"
10+
)
11+
12+
var instr *instrumentation.Instrumentation
13+
14+
func init() {
15+
instr = instrumentation.Load(instrumentation.PackageMark3LabsMCPGo)
16+
}

contrib/mark3labs/mcp-go/option.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import (
99
"github.com/mark3labs/mcp-go/server"
1010
)
1111

12-
// The file contains methods for easily adding tracing to a MCP server.
12+
// The file contains methods for easily adding tracing and intent capture to a MCP server.
1313

1414
// TracingConfig holds configuration for adding tracing to an MCP server.
1515
type TracingConfig struct {
1616
// Hooks allows you to provide custom hooks that will be merged with Datadog tracing hooks.
1717
// If nil, only Datadog tracing hooks will be added and any custom hooks provided via server.WithHooks(...) will be removed.
1818
// If provided, your custom hooks will be executed alongside Datadog tracing hooks.
19-
Hooks *server.Hooks
19+
Hooks *server.Hooks
20+
IntentCaptureEnabled bool
2021
}
2122

2223
// WithTracing adds Datadog tracing to an MCP server.
@@ -54,6 +55,12 @@ func WithTracing(options *TracingConfig) server.ServerOption {
5455

5556
server.WithHooks(hooks)(s)
5657

58+
if options.IntentCaptureEnabled {
59+
hooks.AddAfterListTools(injectDdtraceListToolsHook)
60+
server.WithToolHandlerMiddleware(processAndRemoveDdtraceToolMiddleware)(s)
61+
}
62+
63+
// This must be after the intent capture middleware, so that span in available. Last registered middleware is run first.
5764
server.WithToolHandlerMiddleware(toolHandlerMiddleware)(s)
5865
}
5966
}

contrib/mark3labs/mcp-go/tracing.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,6 @@ import (
1717
"github.com/mark3labs/mcp-go/server"
1818
)
1919

20-
var instr *instrumentation.Instrumentation
21-
22-
func init() {
23-
instr = instrumentation.Load(instrumentation.PackageMark3LabsMCPGo)
24-
}
25-
2620
type hooks struct {
2721
spanCache *sync.Map
2822
}

internal/llmobs/llmobs.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ type llmobsContext struct {
113113
outputMessages []LLMMessage
114114
outputText string
115115

116+
// tool specific
117+
intent string
118+
116119
// experiment specific
117120
experimentInput any
118121
experimentExpectedOutput any
@@ -513,6 +516,14 @@ func (l *LLMObs) llmobsSpanEvent(span *Span) *transport.LLMObsSpanEvent {
513516
meta["tool_definitions"] = toolDefinitions
514517
}
515518

519+
if intent := span.llmCtx.intent; intent != "" {
520+
if spanKind != SpanKindTool {
521+
log.Warn("llmobs: dropping intent on non-tool span kind, annotating intent is only supported for tool span kinds")
522+
} else {
523+
meta["intent"] = intent
524+
}
525+
}
526+
516527
spanStatus := "ok"
517528
var errMsg *transport.ErrorMessage
518529
if span.error != nil {

internal/llmobs/llmobs_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,17 @@ func TestSpanAnnotate(t *testing.T) {
568568
},
569569
wantSessionID: "config-session-456",
570570
},
571+
{
572+
name: "tool-span-with-intent",
573+
kind: llmobs.SpanKindTool,
574+
annotations: llmobs.SpanAnnotations{
575+
Intent: "test intent",
576+
},
577+
wantMeta: map[string]any{
578+
"span.kind": "tool",
579+
"intent": "test intent",
580+
},
581+
},
571582
}
572583

573584
for _, tc := range testCases {

internal/llmobs/span.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ type SpanAnnotations struct {
191191
// ToolDefinitions are the tool definitions for LLM spans.
192192
ToolDefinitions []ToolDefinition
193193

194+
// Intent is a description of a reason for calling an MCP tool on tool spans
195+
Intent string
196+
194197
// AgentManifest is the agent manifest for agent spans.
195198
AgentManifest string
196199

@@ -368,6 +371,14 @@ func (s *Span) Annotate(a SpanAnnotations) {
368371
}
369372
}
370373

374+
if a.Intent != "" {
375+
if s.spanKind != SpanKindTool {
376+
log.Warn("llmobs: intent can only be annotated on tool spans, ignoring")
377+
} else {
378+
s.llmCtx.intent = a.Intent
379+
}
380+
}
381+
371382
s.annotateIO(a)
372383
}
373384

llmobs/option.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,11 @@ func WithAnnotatedMetrics(metrics map[string]float64) AnnotateOption {
154154
}
155155
}
156156
}
157+
158+
// WithIntent sets the intent for the span.
159+
// Intent is a description of a reason for calling an MCP tool.
160+
func WithIntent(intent string) AnnotateOption {
161+
return func(a *illmobs.SpanAnnotations) {
162+
a.Intent = intent
163+
}
164+
}

0 commit comments

Comments
 (0)