Skip to content

Commit f97103f

Browse files
committed
feat(contrib/mark3labs/mcp-go): support per-request intent capture
Add IntentCaptureEnabledFunc to TracingConfig: a per-request predicate that gates intent capture at runtime, enabling toggling per org/feature flag without restarting the server. When non-nil, the predicate is consulted on every tools/list and tools/call: returning false makes that request behave as if intent capture were disabled (no schema injection, no telemetry stripping, no intent annotation). The static IntentCaptureEnabled bool is preserved for back-compat; if both are set the predicate wins. The injection hook and tool-call middleware were refactored into factory functions taking the predicate. Generalizes the conditional intent-capture pattern that the internal dd-source copy uses (no single PR — landed across several commits there).
1 parent 9036f6d commit f97103f

3 files changed

Lines changed: 147 additions & 23 deletions

File tree

contrib/mark3labs/mcp-go/intent_capture.go

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,21 @@ func telemetrySchema() map[string]any {
3232
}
3333
}
3434

35-
// Injects tracing parameters into the tool list response by mutating it.
36-
func injectTelemetryListToolsHook(ctx context.Context, id any, message *mcp.ListToolsRequest, result *mcp.ListToolsResult) {
35+
// injectTelemetryListToolsHookFor returns an AfterListTools hook that injects
36+
// the telemetry parameter into the schemas of model-callable tools, but only
37+
// when the supplied predicate returns true for the request's context.
38+
func injectTelemetryListToolsHookFor(enabled func(context.Context) bool) func(context.Context, any, *mcp.ListToolsRequest, *mcp.ListToolsResult) {
39+
return func(ctx context.Context, id any, message *mcp.ListToolsRequest, result *mcp.ListToolsResult) {
40+
if !enabled(ctx) {
41+
return
42+
}
43+
injectTelemetryListTools(result)
44+
}
45+
}
46+
47+
// injectTelemetryListTools mutates result to add the telemetry parameter to
48+
// each model-callable tool's input schema.
49+
func injectTelemetryListTools(result *mcp.ListToolsResult) {
3750
if result == nil || result.Tools == nil {
3851
return
3952
}
@@ -152,26 +165,36 @@ func ContextWithIntent(ctx context.Context, intent string) context.Context {
152165
return context.WithValue(ctx, intentCtxKey{}, intent)
153166
}
154167

155-
// Removing tracing parameters from the tool call request so its not sent to the tool.
156-
// This must be registered after the tool handler middleware (mcp-go runs middleware in registration order).
157-
// This removes the telemetry parameter before user-defined middleware or tool handlers can see it.
158-
var processAndRemoveTelemetryToolMiddleware = func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
159-
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
160-
if m, ok := request.Params.Arguments.(map[string]any); ok && m != nil {
161-
if telemetryVal, has := m[instrmcp.TelemetryKey]; has {
162-
if telemetryMap, ok := telemetryVal.(map[string]any); ok {
163-
if intent := extractIntent(telemetryMap); intent != "" {
164-
annotateIntentOnSpan(ctx, intent)
165-
ctx = ContextWithIntent(ctx, intent)
168+
// processAndRemoveTelemetryToolMiddlewareFor returns a tool handler middleware
169+
// that strips the telemetry parameter from a tools/call request and annotates
170+
// the active LLMObs span with the supplied intent, but only when the supplied
171+
// predicate returns true for the request's context. When the predicate
172+
// returns false, the middleware is a transparent pass-through.
173+
//
174+
// This must be registered after the tool handler middleware (mcp-go runs
175+
// middleware in registration order). This removes the telemetry parameter
176+
// before user-defined middleware or tool handlers can see it.
177+
func processAndRemoveTelemetryToolMiddlewareFor(enabled func(context.Context) bool) func(server.ToolHandlerFunc) server.ToolHandlerFunc {
178+
return func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
179+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
180+
if !enabled(ctx) {
181+
return next(ctx, request)
182+
}
183+
if m, ok := request.Params.Arguments.(map[string]any); ok && m != nil {
184+
if telemetryVal, has := m[instrmcp.TelemetryKey]; has {
185+
if telemetryMap, ok := telemetryVal.(map[string]any); ok {
186+
if intent := extractIntent(telemetryMap); intent != "" {
187+
annotateIntentOnSpan(ctx, intent)
188+
ctx = ContextWithIntent(ctx, intent)
189+
}
190+
} else if instr != nil && instr.Logger() != nil {
191+
instr.Logger().Warn("mcp-go intent capture: telemetry value is not a map")
166192
}
167-
} else if instr != nil && instr.Logger() != nil {
168-
instr.Logger().Warn("mcp-go intent capture: telemetry value is not a map")
193+
delete(m, instrmcp.TelemetryKey)
169194
}
170-
delete(m, instrmcp.TelemetryKey)
171195
}
196+
return next(ctx, request)
172197
}
173-
174-
return next(ctx, request)
175198
}
176199
}
177200

contrib/mark3labs/mcp-go/intent_capture_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,80 @@ func TestIntentCapture(t *testing.T) {
100100
assert.Equal(t, "test intent description", toolSpan.Meta["intent"])
101101
}
102102

103+
type enabledKey struct{}
104+
105+
func TestIntentCaptureEnabledFunc(t *testing.T) {
106+
tt := testTracer(t)
107+
defer tt.Stop()
108+
109+
srv := server.NewMCPServer("test-server", "1.0.0", WithMCPServerTracing(&TracingConfig{
110+
IntentCaptureEnabledFunc: func(ctx context.Context) bool {
111+
v, _ := ctx.Value(enabledKey{}).(bool)
112+
return v
113+
},
114+
}))
115+
116+
calcTool := mcp.NewTool("calculator",
117+
mcp.WithDescription("A simple calculator"),
118+
mcp.WithString("operation", mcp.Required()))
119+
120+
var seenArgs map[string]any
121+
srv.AddTool(calcTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
122+
seenArgs = request.Params.Arguments.(map[string]any)
123+
return mcp.NewToolResultText(`{"ok":true}`), nil
124+
})
125+
126+
// Disabled per ctx: schema has no telemetry; telemetry in arg passes through.
127+
disabledCtx := context.WithValue(context.Background(), enabledKey{}, false)
128+
listResp := srv.HandleMessage(disabledCtx, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`))
129+
var listResult map[string]interface{}
130+
require.NoError(t, json.Unmarshal(mustMarshal(listResp), &listResult))
131+
props := listResult["result"].(map[string]interface{})["tools"].([]interface{})[0].(map[string]interface{})["inputSchema"].(map[string]interface{})["properties"].(map[string]interface{})
132+
assert.NotContains(t, props, "telemetry")
133+
134+
session := &mockSession{id: "s1"}
135+
session.Initialize()
136+
disabledCtx = srv.WithContext(disabledCtx, session)
137+
srv.HandleMessage(disabledCtx, []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"calculator","arguments":{"operation":"add","telemetry":{"intent":"x"}}}}`))
138+
// Predicate false → middleware is a pass-through, so the telemetry argument is NOT stripped.
139+
require.NotNil(t, seenArgs)
140+
assert.Contains(t, seenArgs, "telemetry")
141+
142+
// Enabled per ctx: schema gets telemetry; tool argument is stripped before handler.
143+
enabledCtx := context.WithValue(context.Background(), enabledKey{}, true)
144+
listResp = srv.HandleMessage(enabledCtx, []byte(`{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}`))
145+
require.NoError(t, json.Unmarshal(mustMarshal(listResp), &listResult))
146+
props = listResult["result"].(map[string]interface{})["tools"].([]interface{})[0].(map[string]interface{})["inputSchema"].(map[string]interface{})["properties"].(map[string]interface{})
147+
assert.Contains(t, props, "telemetry")
148+
149+
session2 := &mockSession{id: "s2"}
150+
session2.Initialize()
151+
enabledCtx = srv.WithContext(enabledCtx, session2)
152+
seenArgs = nil
153+
srv.HandleMessage(enabledCtx, []byte(`{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"calculator","arguments":{"operation":"add","telemetry":{"intent":"x"}}}}`))
154+
require.NotNil(t, seenArgs)
155+
assert.NotContains(t, seenArgs, "telemetry")
156+
}
157+
158+
func TestIntentCaptureEnabledFuncOverridesBool(t *testing.T) {
159+
// When both static bool and the predicate are set, the predicate wins.
160+
srv := server.NewMCPServer("test-server", "1.0.0", WithMCPServerTracing(&TracingConfig{
161+
IntentCaptureEnabled: true,
162+
IntentCaptureEnabledFunc: func(context.Context) bool { return false },
163+
}))
164+
165+
tool := mcp.NewTool("t", mcp.WithDescription("t"), mcp.WithString("q", mcp.Required()))
166+
srv.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
167+
return mcp.NewToolResultText("ok"), nil
168+
})
169+
170+
listResp := srv.HandleMessage(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`))
171+
var listResult map[string]interface{}
172+
require.NoError(t, json.Unmarshal(mustMarshal(listResp), &listResult))
173+
props := listResult["result"].(map[string]interface{})["tools"].([]interface{})[0].(map[string]interface{})["inputSchema"].(map[string]interface{})["properties"].(map[string]interface{})
174+
assert.NotContains(t, props, "telemetry")
175+
}
176+
103177
func TestIntentCaptureSkipsUIOnlyTools(t *testing.T) {
104178
tt := testTracer(t)
105179
defer tt.Stop()

contrib/mark3labs/mcp-go/option.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
package mcpgo
77

88
import (
9+
"context"
10+
911
"github.com/mark3labs/mcp-go/server"
1012
)
1113

@@ -17,9 +19,33 @@ type TracingConfig struct {
1719
// If nil, only Datadog tracing hooks will be added and any custom hooks provided via server.WithHooks(...) will be removed.
1820
// If provided, your custom hooks will be executed alongside Datadog tracing hooks.
1921
Hooks *server.Hooks
20-
// Enables intent capture for tool spans.
21-
// This will modify the tool schemas to include a parameter for the client to provide the intent.
22+
// IntentCaptureEnabled enables intent capture for tool spans unconditionally.
23+
// This modifies the tool schemas to include a parameter for the client to provide the intent.
24+
// For per-request control (e.g. driven by a feature flag), use IntentCaptureEnabledFunc instead.
2225
IntentCaptureEnabled bool
26+
// IntentCaptureEnabledFunc is a per-request predicate that gates intent capture
27+
// at runtime. When non-nil, the intent-capture hook and middleware are registered
28+
// and consult the predicate on every tools/list and tools/call: if it returns
29+
// false, that request behaves as if intent capture were disabled (no schema
30+
// injection, no telemetry stripping, no intent annotation).
31+
//
32+
// If both IntentCaptureEnabled and IntentCaptureEnabledFunc are set, the
33+
// predicate wins. If both are unset, intent capture is disabled.
34+
IntentCaptureEnabledFunc func(ctx context.Context) bool
35+
}
36+
37+
// intentCapturePredicate returns the runtime predicate for intent capture,
38+
// or nil if intent capture is disabled. The predicate from
39+
// IntentCaptureEnabledFunc takes precedence over the static
40+
// IntentCaptureEnabled flag.
41+
func intentCapturePredicate(options *TracingConfig) func(context.Context) bool {
42+
if options.IntentCaptureEnabledFunc != nil {
43+
return options.IntentCaptureEnabledFunc
44+
}
45+
if options.IntentCaptureEnabled {
46+
return func(context.Context) bool { return true }
47+
}
48+
return nil
2349
}
2450

2551
// WithMCPServerTracing adds Datadog tracing to an MCP server.
@@ -61,10 +87,11 @@ func WithMCPServerTracing(options *TracingConfig) server.ServerOption {
6187
// Note: mcp-go middleware runs in registration order (first registered runs first)
6288
server.WithToolHandlerMiddleware(toolHandlerMiddleware)(s)
6389

64-
if options.IntentCaptureEnabled {
65-
hooks.AddAfterListTools(injectTelemetryListToolsHook)
90+
predicate := intentCapturePredicate(options)
91+
if predicate != nil {
92+
hooks.AddAfterListTools(injectTelemetryListToolsHookFor(predicate))
6693
// Register intent capture middleware second so it runs second (after span is created)
67-
server.WithToolHandlerMiddleware(processAndRemoveTelemetryToolMiddleware)(s)
94+
server.WithToolHandlerMiddleware(processAndRemoveTelemetryToolMiddlewareFor(predicate))(s)
6895
}
6996
}
7097
}

0 commit comments

Comments
 (0)