Skip to content

Commit 3eb1396

Browse files
committed
feat(contrib/mark3labs/mcp-go): support redacting tool output in LLMObs traces
Add RedactToolOutput bool to TracingConfig. When set, the tool result body sent to LLMObs is replaced with '[REDACTED]'; the result.IsError flag is still honored for span error status. This is needed when downstream access controls (e.g. org2-dac in the internal dd-source copy) forbid forwarding tool output to LLMObs traces. Generalizes the hardcoded redaction in internal dd-source PR #429591 into a configuration option so the original always-on behavior remains the default for external users.
1 parent 4304847 commit 3eb1396

3 files changed

Lines changed: 136 additions & 45 deletions

File tree

contrib/mark3labs/mcp-go/option.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ type TracingConfig struct {
3232
// If both IntentCaptureEnabled and IntentCaptureEnabledFunc are set, the
3333
// predicate wins. If both are unset, intent capture is disabled.
3434
IntentCaptureEnabledFunc func(ctx context.Context) bool
35+
// RedactToolOutput replaces the tool result body sent to LLMObs with a
36+
// fixed "[REDACTED]" string. Use this when downstream access controls
37+
// (e.g. org2-dac) forbid forwarding tool output to LLMObs traces.
38+
//
39+
// The result.IsError flag is still honored for span error status — only
40+
// the output content is redacted.
41+
RedactToolOutput bool
3542
}
3643

3744
// intentCapturePredicate returns the runtime predicate for intent capture,
@@ -85,7 +92,7 @@ func WithMCPServerTracing(options *TracingConfig) server.ServerOption {
8592

8693
// Register toolHandlerMiddleware first so it runs first (creates the span)
8794
// Note: mcp-go middleware runs in registration order (first registered runs first)
88-
server.WithToolHandlerMiddleware(toolHandlerMiddleware)(s)
95+
server.WithToolHandlerMiddleware(newToolHandlerMiddleware(options.RedactToolOutput))(s)
8996

9097
predicate := intentCapturePredicate(options)
9198
if predicate != nil {

contrib/mark3labs/mcp-go/tracing.go

Lines changed: 62 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -31,57 +31,76 @@ func appendTracingHooks(hooks *server.Hooks) {
3131
hooks.AddOnError(tracingHooks.onError)
3232
}
3333

34-
var toolHandlerMiddleware = func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
35-
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
36-
startOpts := []llmobs.StartSpanOption{
37-
llmobs.WithIntegration(string(instrumentation.PackageMark3LabsMCPGo)),
38-
}
39-
if session := server.ClientSessionFromContext(ctx); session != nil {
40-
startOpts = append(startOpts, llmobs.WithSessionID(session.SessionID()))
41-
}
42-
toolSpan, ctx := llmobs.StartToolSpan(ctx, request.Params.Name, startOpts...)
34+
// redactedOutput is the placeholder substituted for the tool result body
35+
// when TracingConfig.RedactToolOutput is set.
36+
const redactedOutput = "[REDACTED]"
37+
38+
// newToolHandlerMiddleware returns the tool-call middleware. When
39+
// redactOutput is true, the tool result body sent to LLMObs is replaced
40+
// with redactedOutput; result.IsError is still honored for span error status.
41+
func newToolHandlerMiddleware(redactOutput bool) func(server.ToolHandlerFunc) server.ToolHandlerFunc {
42+
return func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
43+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
44+
startOpts := []llmobs.StartSpanOption{
45+
llmobs.WithIntegration(string(instrumentation.PackageMark3LabsMCPGo)),
46+
}
47+
if session := server.ClientSessionFromContext(ctx); session != nil {
48+
startOpts = append(startOpts, llmobs.WithSessionID(session.SessionID()))
49+
}
50+
toolSpan, ctx := llmobs.StartToolSpan(ctx, request.Params.Name, startOpts...)
4351

44-
var result *mcp.CallToolResult
45-
var err error
52+
var result *mcp.CallToolResult
53+
var err error
4654

47-
defer func() {
48-
inputJSON, marshalErr := json.Marshal(request)
49-
if marshalErr != nil {
50-
instr.Logger().Warn("mcp-go: failed to marshal tool request: %v", marshalErr)
51-
}
52-
var outputText string
53-
if result != nil {
54-
resultJSON, marshalErr := json.Marshal(result)
55+
defer func() {
56+
inputJSON, marshalErr := json.Marshal(request)
5557
if marshalErr != nil {
56-
instr.Logger().Warn("mcp-go: failed to marshal tool result: %v", marshalErr)
58+
instr.Logger().Warn("mcp-go: failed to marshal tool request: %v", marshalErr)
5759
}
58-
outputText = string(resultJSON)
59-
}
60+
outputText := toolOutputForLLMObs(result, redactOutput)
61+
62+
toolSpan.Annotate(llmobs.WithAnnotatedTags(map[string]string{
63+
instrmcp.MCPToolTag: request.Params.Name,
64+
instrmcp.MCPToolKindTag: "server",
65+
instrmcp.MCPMethodTag: request.Method,
66+
}))
67+
tagWithSessionID(ctx, toolSpan)
68+
toolSpan.AnnotateTextIO(string(inputJSON), outputText)
69+
70+
// There are two ways a tool can express an error:
71+
// 1. It can return a Go error.
72+
// 2. It can return IsError: true as part of the tool result.
73+
if err != nil {
74+
toolSpan.Finish(llmobs.WithError(err))
75+
} else if result != nil && result.IsError {
76+
toolSpan.Finish(llmobs.WithError(errors.New("tool resulted in an error")))
77+
} else {
78+
toolSpan.Finish()
79+
}
80+
}()
6081

61-
toolSpan.Annotate(llmobs.WithAnnotatedTags(map[string]string{
62-
instrmcp.MCPToolTag: request.Params.Name,
63-
instrmcp.MCPToolKindTag: "server",
64-
instrmcp.MCPMethodTag: request.Method,
65-
}))
66-
tagWithSessionID(ctx, toolSpan)
67-
toolSpan.AnnotateTextIO(string(inputJSON), outputText)
68-
69-
// There are two ways a tool can express an error:
70-
// 1. It can return a Go error.
71-
// 2. It can return IsError: true as part of the tool result.
72-
if err != nil {
73-
toolSpan.Finish(llmobs.WithError(err))
74-
} else if result != nil && result.IsError {
75-
toolSpan.Finish(llmobs.WithError(errors.New("tool resulted in an error")))
76-
} else {
77-
toolSpan.Finish()
78-
}
79-
}()
82+
result, err = next(ctx, request)
8083

81-
result, err = next(ctx, request)
84+
return result, err
85+
}
86+
}
87+
}
8288

83-
return result, err
89+
// toolOutputForLLMObs marshals the tool result for the LLMObs span output
90+
// annotation, or returns the redaction placeholder when output redaction is
91+
// enabled. A nil result is reported as the empty string.
92+
func toolOutputForLLMObs(result *mcp.CallToolResult, redact bool) string {
93+
if result == nil {
94+
return ""
95+
}
96+
if redact {
97+
return redactedOutput
98+
}
99+
resultJSON, marshalErr := json.Marshal(result)
100+
if marshalErr != nil {
101+
instr.Logger().Warn("mcp-go: failed to marshal tool result: %v", marshalErr)
84102
}
103+
return string(resultJSON)
85104
}
86105

87106
func newHooks() *hooks {

contrib/mark3labs/mcp-go/tracing_test.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func TestToolHandlerMiddleware(t *testing.T) {
2525
mt := mocktracer.Start()
2626
defer mt.Stop()
2727

28-
middleware := toolHandlerMiddleware
28+
middleware := newToolHandlerMiddleware(false)
2929
assert.NotNil(t, middleware)
3030
}
3131

@@ -347,6 +347,71 @@ func TestIntegrationToolCallStructuredError(t *testing.T) {
347347
assert.Contains(t, outputStr, "Validation failed")
348348
}
349349

350+
func TestRedactToolOutput(t *testing.T) {
351+
tt := testTracer(t)
352+
defer tt.Stop()
353+
354+
srv := server.NewMCPServer("test-server", "1.0.0",
355+
WithMCPServerTracing(&TracingConfig{RedactToolOutput: true}))
356+
357+
calcTool := mcp.NewTool("calculator", mcp.WithDescription("A simple calculator"))
358+
srv.AddTool(calcTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
359+
return mcp.NewToolResultText(`{"result":42,"secret":"hunter2"}`), nil
360+
})
361+
362+
ctx := context.Background()
363+
session := &mockSession{id: "redact-session"}
364+
session.Initialize()
365+
ctx = srv.WithContext(ctx, session)
366+
367+
srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"calculator","arguments":{}}}`))
368+
369+
spans := tt.WaitForLLMObsSpans(t, 1)
370+
require.Len(t, spans, 1)
371+
372+
toolSpan := spans[0]
373+
outputMeta, ok := toolSpan.Meta["output"].(map[string]interface{})
374+
require.True(t, ok)
375+
assert.Equal(t, "[REDACTED]", outputMeta["value"])
376+
// Sensitive content from the actual result must not leak.
377+
outputJSON, err := json.Marshal(outputMeta)
378+
require.NoError(t, err)
379+
assert.NotContains(t, string(outputJSON), "hunter2")
380+
assert.NotContains(t, string(outputJSON), "42")
381+
}
382+
383+
func TestRedactToolOutputStillReportsIsError(t *testing.T) {
384+
tt := testTracer(t)
385+
defer tt.Stop()
386+
387+
srv := server.NewMCPServer("test-server", "1.0.0",
388+
WithMCPServerTracing(&TracingConfig{RedactToolOutput: true}))
389+
390+
failTool := mcp.NewTool("failing", mcp.WithDescription("A failing tool"))
391+
srv.AddTool(failTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
392+
return mcp.NewToolResultError("permission denied: secret-detail"), nil
393+
})
394+
395+
ctx := context.Background()
396+
session := &mockSession{id: "redact-err"}
397+
session.Initialize()
398+
ctx = srv.WithContext(ctx, session)
399+
400+
srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"failing","arguments":{}}}`))
401+
402+
spans := tt.WaitForLLMObsSpans(t, 1)
403+
require.Len(t, spans, 1)
404+
405+
toolSpan := spans[0]
406+
assert.Contains(t, toolSpan.Meta, "error.message")
407+
outputMeta, ok := toolSpan.Meta["output"].(map[string]interface{})
408+
require.True(t, ok)
409+
assert.Equal(t, "[REDACTED]", outputMeta["value"])
410+
outputJSON, err := json.Marshal(outputMeta)
411+
require.NoError(t, err)
412+
assert.NotContains(t, string(outputJSON), "secret-detail")
413+
}
414+
350415
func TestWithMCPServerTracingWithCustomHooks(t *testing.T) {
351416
tt := testTracer(t)
352417
defer tt.Stop()

0 commit comments

Comments
 (0)