Skip to content

Commit cb69328

Browse files
committed
feat(contrib/mark3labs/mcp-go): skip telemetry injection for UI-only tools
Tools marked with _meta.ui.visibility excluding 'model' are invoked by the app UI, not by the model. OpenAI's MCP client strictly validates tool arguments against the advertised schema, so injecting telemetry as required breaks UI-driven calls that legitimately omit it. Respect the existing visibility annotation: if a tool's visibility list omits 'model', skip telemetry injection entirely. Tools with no _meta.ui.visibility default to model-callable per the MCP Apps spec. Ported from internal dd-source PR #420911.
1 parent 0c444d5 commit cb69328

3 files changed

Lines changed: 113 additions & 0 deletions

File tree

contrib/mark3labs/mcp-go/intent_capture.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ func injectTelemetryListToolsHook(ctx context.Context, id any, message *mcp.List
4444
for i := range result.Tools {
4545
t := &result.Tools[i]
4646

47+
// UI-only tools (_meta.ui.visibility without "model") are invoked by the
48+
// app UI, not by the model. OpenAI's MCP client strictly validates tool
49+
// arguments against the advertised schema, so injecting telemetry as
50+
// required would break UI calls that legitimately omit it.
51+
if !isModelCallable(t.Meta) {
52+
continue
53+
}
54+
4755
// mcp.ToolInputSchema only models type/properties/required/$defs;
4856
// for tools defined with NewToolWithRawSchema we mutate the raw
4957
// JSON via a generic map so keywords like additionalProperties,

contrib/mark3labs/mcp-go/intent_capture_test.go

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

103+
func TestIntentCaptureSkipsUIOnlyTools(t *testing.T) {
104+
tt := testTracer(t)
105+
defer tt.Stop()
106+
107+
srv := server.NewMCPServer("test-server", "1.0.0", WithMCPServerTracing(&TracingConfig{IntentCaptureEnabled: true}))
108+
109+
modelTool := mcp.NewTool("model_tool",
110+
mcp.WithDescription("model-callable"),
111+
mcp.WithString("q", mcp.Required()))
112+
srv.AddTool(modelTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
113+
return mcp.NewToolResultText("ok"), nil
114+
})
115+
116+
uiTool := mcp.NewTool("ui_tool",
117+
mcp.WithDescription("UI-only"),
118+
mcp.WithString("q", mcp.Required()))
119+
uiTool.Meta = &mcp.Meta{AdditionalFields: map[string]any{"ui": map[string]any{"visibility": []string{"app"}}}}
120+
srv.AddTool(uiTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
121+
return mcp.NewToolResultText("ok"), nil
122+
})
123+
124+
dualTool := mcp.NewTool("dual_tool",
125+
mcp.WithDescription("Model and UI"),
126+
mcp.WithString("q", mcp.Required()))
127+
dualTool.Meta = &mcp.Meta{AdditionalFields: map[string]any{"ui": map[string]any{"visibility": []string{"app", "model"}}}}
128+
srv.AddTool(dualTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
129+
return mcp.NewToolResultText("ok"), nil
130+
})
131+
132+
ctx := context.Background()
133+
listResp := srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`))
134+
var listResult map[string]interface{}
135+
require.NoError(t, json.Unmarshal(mustMarshal(listResp), &listResult))
136+
137+
tools := listResult["result"].(map[string]interface{})["tools"].([]interface{})
138+
require.Len(t, tools, 3)
139+
140+
byName := map[string]map[string]interface{}{}
141+
for _, raw := range tools {
142+
tool := raw.(map[string]interface{})
143+
byName[tool["name"].(string)] = tool["inputSchema"].(map[string]interface{})
144+
}
145+
146+
// Model-callable tools get telemetry injected.
147+
for _, name := range []string{"model_tool", "dual_tool"} {
148+
schema := byName[name]
149+
require.NotNil(t, schema, name)
150+
props := schema["properties"].(map[string]interface{})
151+
assert.Contains(t, props, "telemetry", "%s should have telemetry", name)
152+
assert.Contains(t, schema["required"].([]interface{}), "telemetry", "%s should require telemetry", name)
153+
}
154+
155+
// UI-only tool does NOT.
156+
uiSchema := byName["ui_tool"]
157+
require.NotNil(t, uiSchema)
158+
props := uiSchema["properties"].(map[string]interface{})
159+
assert.NotContains(t, props, "telemetry")
160+
if req, ok := uiSchema["required"].([]interface{}); ok {
161+
assert.NotContains(t, req, "telemetry")
162+
}
163+
}
164+
103165
func TestIntentFromContext(t *testing.T) {
104166
ctx := context.Background()
105167

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2025 Datadog, Inc.
5+
6+
package mcpgo
7+
8+
import (
9+
"slices"
10+
11+
"github.com/mark3labs/mcp-go/mcp"
12+
)
13+
14+
// isModelCallable reports whether a tool's _meta permits the model to call it.
15+
// Absent _meta.ui.visibility means model-callable (MCP Apps spec default); a
16+
// visibility list that omits "model" means UI-only and the model should not
17+
// be asked to supply telemetry/intent for it.
18+
func isModelCallable(meta *mcp.Meta) bool {
19+
if meta == nil {
20+
return true
21+
}
22+
uiMap, ok := meta.AdditionalFields["ui"].(map[string]any)
23+
if !ok {
24+
return true
25+
}
26+
raw, ok := uiMap["visibility"]
27+
if !ok {
28+
return true
29+
}
30+
switch v := raw.(type) {
31+
case []string:
32+
return slices.Contains(v, "model")
33+
case []any:
34+
for _, item := range v {
35+
if s, ok := item.(string); ok && s == "model" {
36+
return true
37+
}
38+
}
39+
return false
40+
default:
41+
return true
42+
}
43+
}

0 commit comments

Comments
 (0)