Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contrib/mark3labs/mcp-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ The integration automatically traces:
- **Tool calls**: Creates LLMObs tool spans with input/output annotation for all tool invocations
- **Session initialization**: Create LLMObs task spans for session initialization, including client information.

The integration can optionally capture "intent" on MCP tool calls. When enabled, this adds a parameter to the schema of each tool to request that the client include an explaination.
The integration can optionally capture "intent" on MCP tool calls. When enabled, this adds a parameter to the schema of each tool to request that the client include an explanation.
This can help provide context in natural language about why tools are being used.
33 changes: 15 additions & 18 deletions contrib/mark3labs/mcp-go/intent_capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,22 @@ import (
"context"
"slices"

instrmcp "github.com/DataDog/dd-trace-go/v2/instrumentation/mcp"
"github.com/DataDog/dd-trace-go/v2/llmobs"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)

const ddtraceKey = "ddtrace"

const intentPrompt string = "Briefly describe the wider context task, and why this tool was chosen. Omit argument values, PII/secrets. Use English."

func ddtraceSchema() map[string]any {
func ddTraceSchema() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"intent": map[string]any{
instrmcp.IntentKey: map[string]any{
"type": "string",
"description": intentPrompt,
"description": instrmcp.IntentPrompt,
},
},
"required": []string{"intent"},
"required": []string{instrmcp.IntentKey},
"additionalProperties": false,
}
}
Expand All @@ -54,41 +51,41 @@ func injectDdtraceListToolsHook(ctx context.Context, id any, message *mcp.ListTo
}

// Insert/overwrite the ddtrace property
t.InputSchema.Properties[ddtraceKey] = ddtraceSchema()
t.InputSchema.Properties[instrmcp.DDTraceKey] = ddTraceSchema()

// Mark ddtrace as required (idempotent)
if !slices.Contains(t.InputSchema.Required, ddtraceKey) {
t.InputSchema.Required = append(t.InputSchema.Required, ddtraceKey)
if !slices.Contains(t.InputSchema.Required, instrmcp.DDTraceKey) {
t.InputSchema.Required = append(t.InputSchema.Required, instrmcp.DDTraceKey)
}
}
}

// Removing tracing parameters from the tool call request so its not sent to the tool.
// This must be registered after the tool handler middleware (mcp-go runs middleware in registration order).
// This removes the ddtrace parameter before user-defined middleware or tool handlers can see it.
var processAndRemoveDdtraceToolMiddleware = func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
var processAndRemoveDDTraceToolMiddleware = func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if m, ok := request.Params.Arguments.(map[string]any); ok && m != nil {
if ddtraceVal, has := m[ddtraceKey]; has {
if ddtraceVal, has := m[instrmcp.DDTraceKey]; has {
if ddtraceMap, ok := ddtraceVal.(map[string]any); ok {
processDdtrace(ctx, ddtraceMap)
processDDTrace(ctx, ddtraceMap)
} else if instr != nil && instr.Logger() != nil {
instr.Logger().Warn("mcp-go intent capture: ddtrace value is not a map")
}
delete(m, ddtraceKey)
delete(m, instrmcp.DDTraceKey)
}
}

return next(ctx, request)
}
}

func processDdtrace(ctx context.Context, m map[string]any) {
if m == nil {
func processDDTrace(ctx context.Context, ddTraceVal map[string]any) {
if ddTraceVal == nil {
return
}

intentVal, exists := m["intent"]
intentVal, exists := ddTraceVal[instrmcp.IntentKey]
if !exists {
return
}
Expand Down
3 changes: 2 additions & 1 deletion contrib/mark3labs/mcp-go/intent_capture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"encoding/json"
"testing"

instrmcp "github.com/DataDog/dd-trace-go/v2/instrumentation/mcp"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -59,7 +60,7 @@ func TestIntentCapture(t *testing.T) {
ddtraceProps := ddtraceSchema["properties"].(map[string]interface{})
intentSchema := ddtraceProps["intent"].(map[string]interface{})
assert.Equal(t, "string", intentSchema["type"])
assert.Equal(t, intentPrompt, intentSchema["description"])
assert.Equal(t, instrmcp.IntentPrompt, intentSchema["description"])

required := schema["required"].([]interface{})
assert.Contains(t, required, "operation")
Expand Down
2 changes: 1 addition & 1 deletion contrib/mark3labs/mcp-go/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func WithMCPServerTracing(options *TracingConfig) server.ServerOption {
if options.IntentCaptureEnabled {
hooks.AddAfterListTools(injectDdtraceListToolsHook)
// Register intent capture middleware second so it runs second (after span is created)
server.WithToolHandlerMiddleware(processAndRemoveDdtraceToolMiddleware)(s)
server.WithToolHandlerMiddleware(processAndRemoveDDTraceToolMiddleware)(s)
}
}
}
13 changes: 11 additions & 2 deletions contrib/modelcontextprotocol/go-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@ func main() {
defer tracer.Stop()

server := mcp.NewServer(&mcp.Implementation{Name: "my-server", Version: "1.0.0"}, nil)
gosdktrace.AddTracingMiddleware(server)

// Add tracing middleware
gosdktrace.AddTracing(server)

// Or with intent capture enabled:
// gosdktrace.AddTracing(server, gosdktrace.WithIntentCapture())
}
```

## Features

The integration automatically traces:
`AddTracing` automatically traces:
- **Tool calls**: Creates LLMObs tool spans with input/output annotation for all tool invocations
- **Session initialization**: Creates LLMObs task spans for session initialization, including client information

`WithIntentCapture()` option enables context capture:
When enabled, this adds a parameter to the schema of each tool to request that the client include an explanation of its use.
This can help provide context in natural language about why tools are being used.

2 changes: 1 addition & 1 deletion contrib/modelcontextprotocol/go-sdk/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ func Example() {
defer tracer.Stop()

server := mcp.NewServer(&mcp.Implementation{Name: "my-server", Version: "1.0.0"}, nil)
gosdktrace.AddTracingMiddleware(server)
gosdktrace.AddTracing(server, gosdktrace.WithIntentCapture())
_ = server
}
131 changes: 131 additions & 0 deletions contrib/modelcontextprotocol/go-sdk/intent_capture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025 Datadog, Inc.

package gosdk

import (
"context"
"encoding/json"

instrmcp "github.com/DataDog/dd-trace-go/v2/instrumentation/mcp"
"github.com/DataDog/dd-trace-go/v2/llmobs"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

func ddTraceSchema() *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
instrmcp.IntentKey: {
Type: "string",
Description: instrmcp.IntentPrompt,
},
},
Required: []string{instrmcp.IntentKey},
}
}

// intentCaptureReceivingMiddleware is an mcp.Server receiving middleware
// adding intent information to the tool call span.
// Intent capture works by injecting an additional required parameter on tools that the
// client agent will fill in to explain context about the task.
// The middleware records this intent on the span, and then removes it from the arguments before the tool is called.
func intentCaptureReceivingMiddleware(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
switch method {
case "tools/list":
// The additional parameter is added to the tool arguments returned by tools/list.
res, err := next(ctx, method, req)
if toolListRes, ok := res.(*mcp.ListToolsResult); ok {
injectToolsListResponse(toolListRes)
}
return res, err
case "tools/call":
// The intent is recorded and the argument is removed.
if toolReq, ok := req.(*mcp.CallToolRequest); ok {
return processToolCallIntent(next, ctx, method, toolReq)
}
}
return next(ctx, method, req)
}
}

func injectToolsListResponse(res *mcp.ListToolsResult) {
for i := range res.Tools {
inputSchema, ok := res.Tools[i].InputSchema.(*jsonschema.Schema)
if !ok {
instr.Logger().Warn("go-sdk intent capture: unexpected input schema type: %T", res.Tools[i].InputSchema)
continue
}

if inputSchema.Type == "" {
inputSchema.Type = "object"
}
if inputSchema.Properties == nil {
inputSchema.Properties = map[string]*jsonschema.Schema{}
}

inputSchema.Properties[instrmcp.DDTraceKey] = ddTraceSchema()
inputSchema.Required = append(inputSchema.Required, instrmcp.DDTraceKey)
}
}

func processToolCallIntent(next mcp.MethodHandler, ctx context.Context, method string, req *mcp.CallToolRequest) (mcp.Result, error) {
if len(req.Params.Arguments) > 0 {
var argsMap map[string]any
if err := json.Unmarshal(req.Params.Arguments, &argsMap); err != nil {
if instr != nil && instr.Logger() != nil {
instr.Logger().Warn("go-sdk intent capture: failed to unmarshal arguments: %v", err)
}
return next(ctx, method, req)
}

if ddtraceVal, has := argsMap[instrmcp.DDTraceKey]; has {
if ddtraceMap, ok := ddtraceVal.(map[string]any); ok {
annotateSpanWithIntent(ctx, ddtraceMap)
} else {
instr.Logger().Warn("go-sdk intent capture: ddtrace value is not a map")
}

delete(argsMap, instrmcp.DDTraceKey)

modifiedArgs, err := json.Marshal(argsMap)
if err != nil {
instr.Logger().Warn("go-sdk intent capture: failed to marshal modified arguments: %v", err)
} else {
req.Params.Arguments = modifiedArgs
}
}
}
return next(ctx, method, req)
}

func annotateSpanWithIntent(ctx context.Context, ddTraceVal map[string]any) {
if ddTraceVal == nil {
return
}

intentVal, exists := ddTraceVal[instrmcp.IntentKey]
if !exists {
return
}

intent, ok := intentVal.(string)
if !ok || intent == "" {
return
}

span, ok := llmobs.SpanFromContext(ctx)
if !ok {
return
}

toolSpan, ok := span.AsTool()
if !ok {
return
}
toolSpan.Annotate(llmobs.WithIntent(intent))
}
Loading
Loading