Skip to content

Commit d0e5464

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 0f308e2 commit d0e5464

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

contrib/mark3labs/mcp-go/intent_capture.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,37 @@ func telemetrySchema() map[string]any {
3232
}
3333
}
3434

35+
// isModelCallable reports whether a tool's _meta permits the model to call it.
36+
// Absent _meta.ui.visibility means model-callable (MCP Apps spec default); a
37+
// visibility list that omits "model" means UI-only and the model should not
38+
// be asked to supply telemetry/intent for it.
39+
func isModelCallable(meta *mcp.Meta) bool {
40+
if meta == nil {
41+
return true
42+
}
43+
uiMap, ok := meta.AdditionalFields["ui"].(map[string]any)
44+
if !ok {
45+
return true
46+
}
47+
raw, ok := uiMap["visibility"]
48+
if !ok {
49+
return true
50+
}
51+
switch v := raw.(type) {
52+
case []string:
53+
return slices.Contains(v, "model")
54+
case []any:
55+
for _, item := range v {
56+
if s, ok := item.(string); ok && s == "model" {
57+
return true
58+
}
59+
}
60+
return false
61+
default:
62+
return true
63+
}
64+
}
65+
3566
// Injects tracing parameters into the tool list response by mutating it.
3667
func injectTelemetryListToolsHook(ctx context.Context, id any, message *mcp.ListToolsRequest, result *mcp.ListToolsResult) {
3768
if result == nil || result.Tools == nil {
@@ -44,6 +75,14 @@ func injectTelemetryListToolsHook(ctx context.Context, id any, message *mcp.List
4475
for i := range result.Tools {
4576
t := &result.Tools[i]
4677

78+
// UI-only tools (_meta.ui.visibility without "model") are invoked by the
79+
// app UI, not by the model. OpenAI's MCP client strictly validates tool
80+
// arguments against the advertised schema, so injecting telemetry as
81+
// required would break UI calls that legitimately omit it.
82+
if !isModelCallable(t.Meta) {
83+
continue
84+
}
85+
4786
if t.RawInputSchema != nil {
4887
var schema mcp.ToolInputSchema
4988
if err := json.Unmarshal(t.RawInputSchema, &schema); err != nil {

contrib/mark3labs/mcp-go/intent_capture_test.go

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

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

0 commit comments

Comments
 (0)