Skip to content

Commit 2ff4a7b

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 2de53a9 commit 2ff4a7b

3 files changed

Lines changed: 113 additions & 0 deletions

File tree

contrib/mark3labs/mcp-go/intent_capture.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ 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+
var toolMeta map[string]any
52+
if t.Meta != nil {
53+
toolMeta = t.Meta.AdditionalFields
54+
}
55+
if !instrmcp.IsModelCallable(toolMeta) {
56+
continue
57+
}
58+
4759
// mcp.ToolInputSchema only models type/properties/required/$defs;
4860
// for tools defined with NewToolWithRawSchema we mutate the raw
4961
// 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

instrumentation/mcp/mcp.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
package mcp
77

8+
import "slices"
9+
810
const TelemetryKey = "telemetry"
911

1012
const IntentKey = "intent"
@@ -17,3 +19,40 @@ const MCPMethodTag = "mcp_method"
1719
const MCPSessionIDTag = "mcp_session_id"
1820
const MCPClientNameTag = "client_name"
1921
const MCPClientVersionTag = "client_version"
22+
23+
// IsModelCallable reports whether a tool's _meta permits the model to call it.
24+
// Absent _meta.ui.visibility means model-callable (MCP Apps spec default); a
25+
// visibility list that omits "model" means UI-only and the model should not
26+
// be asked to supply telemetry/intent for it.
27+
//
28+
// Callers pass the raw _meta map regardless of SDK wrapping: mark3labs/mcp-go
29+
// stores it as mcp.Meta.AdditionalFields; modelcontextprotocol/go-sdk uses
30+
// mcp.Meta directly (which is map[string]any).
31+
//
32+
// See https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/SEP-1865.md
33+
func IsModelCallable(meta map[string]any) bool {
34+
if meta == nil {
35+
return true
36+
}
37+
uiMap, ok := meta["ui"].(map[string]any)
38+
if !ok {
39+
return true
40+
}
41+
raw, ok := uiMap["visibility"]
42+
if !ok {
43+
return true
44+
}
45+
switch v := raw.(type) {
46+
case []string:
47+
return slices.Contains(v, "model")
48+
case []any:
49+
for _, item := range v {
50+
if s, ok := item.(string); ok && s == "model" {
51+
return true
52+
}
53+
}
54+
return false
55+
default:
56+
return true
57+
}
58+
}

0 commit comments

Comments
 (0)