Skip to content
Merged
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
13 changes: 13 additions & 0 deletions contrib/mark3labs/mcp-go/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ type TracingConfig struct {
// If both IntentCaptureEnabled and IntentCaptureEnabledFunc are set, the
// predicate wins. If both are unset, intent capture is disabled.
IntentCaptureEnabledFunc func(ctx context.Context) bool
// RedactToolOutput replaces the tool result body sent to LLMObs with a
// fixed "[REDACTED]" string. Use this when data access control policies
// forbid forwarding tool output to LLMObs traces.
//
// The result.IsError flag is still honored for span error status — only
// the output content is redacted.
RedactToolOutput bool

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new tracer customization

This adds a public TracingConfig option that changes what LLMObs records for MCP tool calls, but the commit does not update any docs for users to discover or apply it. The root AGENTS.md says CONTRIBUTING.md should be updated for significant features that introduce a new way of interacting with or customizing the tracer, so please document this new redaction knob (or explain why it is intentionally exempt).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The full intent-capture and redaction feature set in this stack (#4939#4945) is documented in each TracingConfig field's GoDoc rather than CONTRIBUTING.md, consistent with how the contrib's existing Hooks and IntentCaptureEnabled options are documented today. CONTRIBUTING.md currently has no MCP section to extend; adding one for this knob alone would be inconsistent with the rest of the stack.

}

// intentCapturePredicate returns the runtime predicate for intent capture,
Expand Down Expand Up @@ -83,6 +90,12 @@ func WithMCPServerTracing(options *TracingConfig) server.ServerOption {

server.WithHooks(hooks)(s)

// Register the redaction middleware first (when enabled) so its ctx
// flag is set before toolHandlerMiddleware annotates the LLMObs span.
if options.RedactToolOutput {
server.WithToolHandlerMiddleware(redactToolOutputMiddleware)(s)
}

// Register toolHandlerMiddleware first so it runs first (creates the span)
// Note: mcp-go middleware runs in registration order (first registered runs first)
server.WithToolHandlerMiddleware(toolHandlerMiddleware)(s)
Expand Down
25 changes: 21 additions & 4 deletions contrib/mark3labs/mcp-go/tracing.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ func appendTracingHooks(hooks *server.Hooks) {
hooks.AddOnError(tracingHooks.onError)
}

// redactToolOutputKey is the context key set by redactToolOutputMiddleware
// to flag the request's tool output as needing redaction in the LLMObs span.
type redactToolOutputKey struct{}

// redactToolOutputMiddleware is registered when TracingConfig.RedactToolOutput
// is true; it tags the request context so toolHandlerMiddleware redacts the
// tool output it later annotates onto the LLMObs span.
var redactToolOutputMiddleware = func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return next(context.WithValue(ctx, redactToolOutputKey{}, struct{}{}), request)
}
}

var toolHandlerMiddleware = func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
startOpts := []llmobs.StartSpanOption{
Expand All @@ -51,11 +64,15 @@ var toolHandlerMiddleware = func(next server.ToolHandlerFunc) server.ToolHandler
}
var outputText string
if result != nil {
resultJSON, marshalErr := json.Marshal(result)
if marshalErr != nil {
instr.Logger().Warn("mcp-go: failed to marshal tool result: %v", marshalErr)
if _, redact := ctx.Value(redactToolOutputKey{}).(struct{}); redact {
outputText = "[REDACTED]"
} else {
resultJSON, marshalErr := json.Marshal(result)
if marshalErr != nil {
instr.Logger().Warn("mcp-go: failed to marshal tool result: %v", marshalErr)
}
outputText = string(resultJSON)
}
outputText = string(resultJSON)
}

toolSpan.Annotate(llmobs.WithAnnotatedTags(map[string]string{
Expand Down
65 changes: 65 additions & 0 deletions contrib/mark3labs/mcp-go/tracing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,71 @@ func TestIntegrationToolCallStructuredError(t *testing.T) {
assert.Contains(t, outputStr, "Validation failed")
}

func TestRedactToolOutput(t *testing.T) {
tt := testTracer(t)
defer tt.Stop()

srv := server.NewMCPServer("test-server", "1.0.0",
WithMCPServerTracing(&TracingConfig{RedactToolOutput: true}))

calcTool := mcp.NewTool("calculator", mcp.WithDescription("A simple calculator"))
srv.AddTool(calcTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultText(`{"result":42,"secret":"hunter2"}`), nil
})

ctx := context.Background()
session := &mockSession{id: "redact-session"}
session.Initialize()
ctx = srv.WithContext(ctx, session)

srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"calculator","arguments":{}}}`))

spans := tt.WaitForLLMObsSpans(t, 1)
require.Len(t, spans, 1)

toolSpan := spans[0]
outputMeta, ok := toolSpan.Meta["output"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "[REDACTED]", outputMeta["value"])
// Sensitive content from the actual result must not leak.
outputJSON, err := json.Marshal(outputMeta)
require.NoError(t, err)
assert.NotContains(t, string(outputJSON), "hunter2")
assert.NotContains(t, string(outputJSON), "42")
}

func TestRedactToolOutputStillReportsIsError(t *testing.T) {
tt := testTracer(t)
defer tt.Stop()

srv := server.NewMCPServer("test-server", "1.0.0",
WithMCPServerTracing(&TracingConfig{RedactToolOutput: true}))

failTool := mcp.NewTool("failing", mcp.WithDescription("A failing tool"))
srv.AddTool(failTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError("permission denied: secret-detail"), nil
})

ctx := context.Background()
session := &mockSession{id: "redact-err"}
session.Initialize()
ctx = srv.WithContext(ctx, session)

srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"failing","arguments":{}}}`))

spans := tt.WaitForLLMObsSpans(t, 1)
require.Len(t, spans, 1)

toolSpan := spans[0]
assert.Contains(t, toolSpan.Meta, "error.message")
outputMeta, ok := toolSpan.Meta["output"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "[REDACTED]", outputMeta["value"])
outputJSON, err := json.Marshal(outputMeta)
require.NoError(t, err)
assert.NotContains(t, string(outputJSON), "secret-detail")
}

func TestWithMCPServerTracingWithCustomHooks(t *testing.T) {
tt := testTracer(t)
defer tt.Stop()
Expand Down
Loading