Skip to content

Commit 849cb43

Browse files
[mq] [skip ddci] working branch - merge 2de53a9 on top of main at 8697bff
{"baseBranch":"main","baseCommit":"8697bff9e2a57cb239bad689203a80b7bb7beadc","createdAt":"2026-07-02T20:36:49.692435Z","headSha":"2de53a967f36bb22cf5c7f75d6dd746a76a7506f","id":"7ef6f8b7-7160-4782-8d31-af31cdf91f16","mergeMethod":"merge","priority":"200","pullRequestNumber":"4941","queuedAt":"2026-07-02T20:36:49.691254Z","status":"STATUS_QUEUED"}
2 parents 0708985 + 2de53a9 commit 849cb43

2 files changed

Lines changed: 106 additions & 9 deletions

File tree

contrib/mark3labs/mcp-go/intent_capture.go

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,36 @@ func injectTelemetryIntoRawSchema(raw json.RawMessage) (json.RawMessage, bool) {
110110
return out, true
111111
}
112112

113+
// intentCtxKey is the context key used to stash the captured intent so tool
114+
// handlers can forward it downstream (e.g. to a search API). Kept unexported
115+
// to force callers through IntentFromContext.
116+
type intentCtxKey struct{}
117+
118+
// IntentFromContext returns the captured intent for the current MCP tool call,
119+
// if intent capture is enabled and the client supplied a non-empty
120+
// telemetry.intent. The boolean is false when no intent is available.
121+
func IntentFromContext(ctx context.Context) (string, bool) {
122+
if ctx == nil {
123+
return "", false
124+
}
125+
v, ok := ctx.Value(intentCtxKey{}).(string)
126+
if !ok || v == "" {
127+
return "", false
128+
}
129+
return v, true
130+
}
131+
132+
// ContextWithIntent returns a copy of ctx that carries the given intent. The
133+
// middleware uses this internally; it is exported so tests (and callers that
134+
// fabricate their own contexts outside the standard middleware chain) can seed
135+
// the value that IntentFromContext will later read.
136+
func ContextWithIntent(ctx context.Context, intent string) context.Context {
137+
if intent == "" {
138+
return ctx
139+
}
140+
return context.WithValue(ctx, intentCtxKey{}, intent)
141+
}
142+
113143
// Removing tracing parameters from the tool call request so its not sent to the tool.
114144
// This must be registered after the tool handler middleware (mcp-go runs middleware in registration order).
115145
// This removes the telemetry parameter before user-defined middleware or tool handlers can see it.
@@ -118,7 +148,10 @@ var processAndRemoveTelemetryToolMiddleware = func(next server.ToolHandlerFunc)
118148
if m, ok := request.Params.Arguments.(map[string]any); ok && m != nil {
119149
if telemetryVal, has := m[instrmcp.TelemetryKey]; has {
120150
if telemetryMap, ok := telemetryVal.(map[string]any); ok {
121-
processTelemetry(ctx, telemetryMap)
151+
if intent := extractIntent(telemetryMap); intent != "" {
152+
annotateIntentOnSpan(ctx, intent)
153+
ctx = ContextWithIntent(ctx, intent)
154+
}
122155
} else if instr != nil && instr.Logger() != nil {
123156
instr.Logger().Warn("mcp-go intent capture: telemetry value is not a map")
124157
}
@@ -130,26 +163,35 @@ var processAndRemoveTelemetryToolMiddleware = func(next server.ToolHandlerFunc)
130163
}
131164
}
132165

133-
func processTelemetry(ctx context.Context, telemetryVal map[string]any) {
166+
// extractIntent pulls the intent string out of the telemetry map supplied by
167+
// the MCP client. It returns "" when the entry is missing, the wrong type, or
168+
// empty — callers should treat that as "no intent" and skip further work.
169+
func extractIntent(telemetryVal map[string]any) string {
134170
if telemetryVal == nil {
135-
return
171+
return ""
136172
}
137-
138173
intentVal, exists := telemetryVal[instrmcp.IntentKey]
139174
if !exists {
140-
return
175+
return ""
141176
}
142-
143177
intent, ok := intentVal.(string)
144-
if !ok || intent == "" {
145-
return
178+
if !ok {
179+
return ""
146180
}
181+
return intent
182+
}
147183

184+
// annotateIntentOnSpan records intent on the active LLM Obs tool span, if one
185+
// is present on ctx. It is a no-op when no span is active or the active span
186+
// is not a tool span, so it is always safe to call.
187+
func annotateIntentOnSpan(ctx context.Context, intent string) {
188+
if intent == "" {
189+
return
190+
}
148191
span, ok := llmobs.SpanFromContext(ctx)
149192
if !ok {
150193
return
151194
}
152-
153195
toolSpan, ok := span.AsTool()
154196
if !ok {
155197
return

contrib/mark3labs/mcp-go/intent_capture_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func TestIntentCapture(t *testing.T) {
2525
srv := server.NewMCPServer("test-server", "1.0.0", WithMCPServerTracing(&TracingConfig{IntentCaptureEnabled: true}))
2626

2727
var receivedArgs map[string]any
28+
var receivedIntent string
29+
var receivedIntentOK bool
2830
calcTool := mcp.NewTool("calculator",
2931
mcp.WithDescription("A simple calculator"),
3032
mcp.WithString("operation", mcp.Required(), mcp.Description("The operation to perform")),
@@ -33,6 +35,7 @@ func TestIntentCapture(t *testing.T) {
3335

3436
srv.AddTool(calcTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
3537
receivedArgs = request.Params.Arguments.(map[string]any)
38+
receivedIntent, receivedIntentOK = IntentFromContext(ctx)
3639
return mcp.NewToolResultText(`{"result":8}`), nil
3740
})
3841

@@ -81,6 +84,11 @@ func TestIntentCapture(t *testing.T) {
8184
assert.Equal(t, float64(3), receivedArgs["y"])
8285
assert.NotContains(t, receivedArgs, "telemetry")
8386

87+
// Verify intent was stashed in ctx so the handler can forward it downstream
88+
// (e.g. into a search API request) without re-reading the telemetry blob.
89+
assert.True(t, receivedIntentOK, "IntentFromContext should report a value")
90+
assert.Equal(t, "test intent description", receivedIntent)
91+
8492
// Verify intent was recorded on the LLMObs span
8593
spans := tt.WaitForLLMObsSpans(t, 1)
8694
require.Len(t, spans, 1)
@@ -92,6 +100,53 @@ func TestIntentCapture(t *testing.T) {
92100
assert.Equal(t, "test intent description", toolSpan.Meta["intent"])
93101
}
94102

103+
func TestIntentFromContext(t *testing.T) {
104+
ctx := context.Background()
105+
106+
_, ok := IntentFromContext(ctx)
107+
assert.False(t, ok)
108+
109+
ctx2 := ContextWithIntent(ctx, "find recent errors")
110+
got, ok := IntentFromContext(ctx2)
111+
assert.True(t, ok)
112+
assert.Equal(t, "find recent errors", got)
113+
114+
// Empty intent does not seed the context.
115+
ctx3 := ContextWithIntent(ctx, "")
116+
_, ok = IntentFromContext(ctx3)
117+
assert.False(t, ok)
118+
}
119+
120+
func TestIntentFromContext_AbsentWhenNoTelemetry(t *testing.T) {
121+
tt := testTracer(t)
122+
defer tt.Stop()
123+
124+
srv := server.NewMCPServer("test-server", "1.0.0", WithMCPServerTracing(&TracingConfig{IntentCaptureEnabled: true}))
125+
126+
var receivedIntent string
127+
var receivedIntentOK bool
128+
calcTool := mcp.NewTool("calculator",
129+
mcp.WithDescription("A simple calculator"),
130+
mcp.WithString("operation", mcp.Required(), mcp.Description("The operation to perform")),
131+
mcp.WithNumber("x", mcp.Required(), mcp.Description("First number")),
132+
mcp.WithNumber("y", mcp.Required(), mcp.Description("Second number")))
133+
134+
srv.AddTool(calcTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
135+
receivedIntent, receivedIntentOK = IntentFromContext(ctx)
136+
return mcp.NewToolResultText(`{"result":8}`), nil
137+
})
138+
139+
ctx := context.Background()
140+
session := &mockSession{id: "test"}
141+
session.Initialize()
142+
ctx = srv.WithContext(ctx, session)
143+
144+
srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"calculator","arguments":{"operation":"add","x":5,"y":3}}}`))
145+
146+
assert.False(t, receivedIntentOK, "IntentFromContext should be empty when no telemetry was supplied")
147+
assert.Empty(t, receivedIntent)
148+
}
149+
95150
func TestIntentCaptureRawInputSchemaViaNewToolListsWithoutConflict(t *testing.T) {
96151
// mcp.NewTool defaults InputSchema.Type to "object"; combined with
97152
// WithRawInputSchema this leaves BOTH set, and Tool.MarshalJSON refuses

0 commit comments

Comments
 (0)