Skip to content

Commit e8264c6

Browse files
committed
feat(contrib/modelcontextprotocol/go-sdk): skip intent capture for UI-only tools
1 parent 5fc323c commit e8264c6

3 files changed

Lines changed: 101 additions & 0 deletions

File tree

contrib/modelcontextprotocol/go-sdk/intent_capture.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ func intentCaptureReceivingMiddleware(next mcp.MethodHandler) mcp.MethodHandler
5656

5757
func injectToolsListResponse(res *mcp.ListToolsResult) {
5858
for i := range res.Tools {
59+
// UI-only tools cannot be invoked by the model, so injecting a
60+
// required telemetry parameter would be useless noise.
61+
if !instrmcp.IsModelCallable(res.Tools[i].Meta) {
62+
continue
63+
}
5964
// Round-trip the input schema through map[string]any so unknown JSON
6065
// Schema keywords (additionalProperties, oneOf, patternProperties, etc.)
6166
// that *jsonschema.Schema does not model pass through verbatim.

contrib/modelcontextprotocol/go-sdk/intent_capture_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,63 @@ func TestIntentCapturePreservesUnknownSchemaKeywords(t *testing.T) {
6767
assert.NotNil(t, schema["oneOf"])
6868
}
6969

70+
func TestIntentCaptureSkipsUIOnlyTools(t *testing.T) {
71+
// Tools whose _meta.ui.visibility omits "model" cannot be model-invoked, so
72+
// telemetry injection should be skipped for them.
73+
tt := testTracer(t)
74+
defer tt.Stop()
75+
ctx := context.Background()
76+
77+
server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "1.0.0"}, nil)
78+
AddTracing(server, WithIntentCapture())
79+
80+
addTool := func(name string, visibility []any) {
81+
tool := &mcp.Tool{
82+
Name: name,
83+
Description: name,
84+
InputSchema: &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"q": {Type: "string"}}},
85+
}
86+
if visibility != nil {
87+
tool.Meta = mcp.Meta{"ui": map[string]any{"visibility": visibility}}
88+
}
89+
server.AddTool(tool, func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
90+
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "ok"}}}, nil
91+
})
92+
}
93+
addTool("plain", nil)
94+
addTool("ui_only", []any{"app"})
95+
addTool("dual", []any{"model", "app"})
96+
97+
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
98+
clientTransport, serverTransport := mcp.NewInMemoryTransports()
99+
serverSession, err := server.Connect(ctx, serverTransport, nil)
100+
require.NoError(t, err)
101+
defer serverSession.Close()
102+
clientSession, err := client.Connect(ctx, clientTransport, nil)
103+
require.NoError(t, err)
104+
defer clientSession.Close()
105+
106+
listResult, err := clientSession.ListTools(ctx, &mcp.ListToolsParams{})
107+
require.NoError(t, err)
108+
props := func(name string) map[string]any {
109+
for _, tl := range listResult.Tools {
110+
if tl.Name != name {
111+
continue
112+
}
113+
schema, ok := tl.InputSchema.(map[string]any)
114+
if !ok {
115+
return nil
116+
}
117+
p, _ := schema["properties"].(map[string]any)
118+
return p
119+
}
120+
return nil
121+
}
122+
assert.Contains(t, props("plain"), "telemetry")
123+
assert.NotContains(t, props("ui_only"), "telemetry", "UI-only tool should not have telemetry injected")
124+
assert.Contains(t, props("dual"), "telemetry")
125+
}
126+
70127
func TestIntentCapture(t *testing.T) {
71128
tt := testTracer(t)
72129
defer tt.Stop()

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)