Skip to content

Commit 8697bff

Browse files
fix(contrib/mark3labs/mcp-go): preserve raw input schema fields on intent injection (#4946)
## Summary `mcp.ToolInputSchema` only models `type`, `properties`, `required`, and `$defs`. Tools registered via `NewToolWithRawSchema` may declare other JSON Schema keywords (`additionalProperties`, `oneOf`, `patternProperties`, `pattern`, `enum`, `minimum`, `$schema`, …) — the previous round-trip through `ToolInputSchema` dropped all of them, so enabling intent capture quietly relaxed every raw-schema tool. Fix: inject telemetry into the raw JSON as a generic `map[string]any` and re-marshal. Unknown keys pass through verbatim. Same bug exists in the dd-source copy (not fixed there). Found by codex-connector on #4939. ## Test plan - [x] `TestIntentCaptureRawInputSchemaPreservesUnknownFields` — raw schema with `additionalProperties: false` and `oneOf` survives injection. - [x] All existing intent-capture tests still pass. Stacked on #4940. Co-authored-by: jboolean <julian.boilen@datadoghq.com>
2 parents 74e067c + e46fd46 commit 8697bff

2 files changed

Lines changed: 98 additions & 5 deletions

File tree

contrib/mark3labs/mcp-go/intent_capture.go

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

47+
// mcp.ToolInputSchema only models type/properties/required/$defs;
48+
// for tools defined with NewToolWithRawSchema we mutate the raw
49+
// JSON via a generic map so keywords like additionalProperties,
50+
// oneOf, or patternProperties pass through verbatim.
4751
if t.RawInputSchema != nil {
48-
var schema mcp.ToolInputSchema
49-
if err := json.Unmarshal(t.RawInputSchema, &schema); err != nil {
50-
continue
52+
if newRaw, ok := injectTelemetryIntoRawSchema(t.RawInputSchema); ok {
53+
t.RawInputSchema = newRaw
54+
// mcp.NewTool sets InputSchema.Type="object" by default; keeping it
55+
// alongside RawInputSchema makes Tool.MarshalJSON return a schema-
56+
// conflict error.
57+
t.InputSchema = mcp.ToolInputSchema{}
5158
}
52-
t.InputSchema = schema
53-
t.RawInputSchema = nil
59+
continue
5460
}
5561

5662
if t.InputSchema.Type == "" {
@@ -72,6 +78,38 @@ func injectTelemetryListToolsHook(ctx context.Context, id any, message *mcp.List
7278
}
7379
}
7480

81+
// injectTelemetryIntoRawSchema mutates a raw JSON Schema document to add the
82+
// telemetry property and require it. Unknown top-level keywords pass through
83+
// verbatim. Returns false when the input can't be parsed as a JSON object.
84+
func injectTelemetryIntoRawSchema(raw json.RawMessage) (json.RawMessage, bool) {
85+
var schema map[string]any
86+
if json.Unmarshal(raw, &schema) != nil || schema == nil {
87+
return nil, false
88+
}
89+
if _, ok := schema["type"]; !ok {
90+
schema["type"] = "object"
91+
}
92+
props, _ := schema["properties"].(map[string]any)
93+
props = maps.Clone(props)
94+
if props == nil {
95+
props = map[string]any{}
96+
}
97+
props[instrmcp.TelemetryKey] = telemetrySchema()
98+
schema["properties"] = props
99+
100+
required, _ := schema["required"].([]any)
101+
if !slices.Contains(required, any(instrmcp.TelemetryKey)) {
102+
required = append(slices.Clone(required), instrmcp.TelemetryKey)
103+
}
104+
schema["required"] = required
105+
106+
out, err := json.Marshal(schema)
107+
if err != nil {
108+
return nil, false
109+
}
110+
return out, true
111+
}
112+
75113
// Removing tracing parameters from the tool call request so its not sent to the tool.
76114
// This must be registered after the tool handler middleware (mcp-go runs middleware in registration order).
77115
// This removes the telemetry parameter before user-defined middleware or tool handlers can see it.

contrib/mark3labs/mcp-go/intent_capture_test.go

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

95+
func TestIntentCaptureRawInputSchemaViaNewToolListsWithoutConflict(t *testing.T) {
96+
// mcp.NewTool defaults InputSchema.Type to "object"; combined with
97+
// WithRawInputSchema this leaves BOTH set, and Tool.MarshalJSON refuses
98+
// to encode a tool with both. Intent capture must clear the structured
99+
// schema when it keeps the raw one.
100+
tt := testTracer(t)
101+
defer tt.Stop()
102+
103+
srv := server.NewMCPServer("test-server", "1.0.0", WithMCPServerTracing(&TracingConfig{IntentCaptureEnabled: true}))
104+
srv.AddTool(mcp.NewTool("raw_tool",
105+
mcp.WithDescription("raw"),
106+
mcp.WithRawInputSchema(json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}`)),
107+
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
108+
return mcp.NewToolResultText("ok"), nil
109+
})
110+
111+
listResp := srv.HandleMessage(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`))
112+
var listResult map[string]interface{}
113+
require.NoError(t, json.Unmarshal(mustMarshal(listResp), &listResult))
114+
115+
require.NotNil(t, listResult["result"], "tools/list returned error: %v", listResult["error"])
116+
tools := listResult["result"].(map[string]interface{})["tools"].([]interface{})
117+
require.Len(t, tools, 1)
118+
props := tools[0].(map[string]interface{})["inputSchema"].(map[string]interface{})["properties"].(map[string]interface{})
119+
assert.Contains(t, props, "telemetry")
120+
}
121+
122+
func TestIntentCaptureRawInputSchemaPreservesUnknownFields(t *testing.T) {
123+
// mcp.ToolInputSchema doesn't model additionalProperties/oneOf/etc;
124+
// intent capture must not silently strip those when injecting telemetry.
125+
tt := testTracer(t)
126+
defer tt.Stop()
127+
128+
srv := server.NewMCPServer("test-server", "1.0.0", WithMCPServerTracing(&TracingConfig{IntentCaptureEnabled: true}))
129+
srv.AddTool(mcp.NewToolWithRawSchema("raw_tool", "raw", json.RawMessage(`{
130+
"type": "object",
131+
"properties": {"app_id": {"type": "string"}},
132+
"required": ["app_id"],
133+
"additionalProperties": false,
134+
"oneOf": [{"required": ["app_id"]}]
135+
}`)), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
136+
return mcp.NewToolResultText("ok"), nil
137+
})
138+
139+
listResp := srv.HandleMessage(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`))
140+
var listResult map[string]interface{}
141+
require.NoError(t, json.Unmarshal(mustMarshal(listResp), &listResult))
142+
schema := listResult["result"].(map[string]interface{})["tools"].([]interface{})[0].(map[string]interface{})["inputSchema"].(map[string]interface{})
143+
144+
assert.Contains(t, schema["properties"].(map[string]interface{}), "telemetry")
145+
assert.Contains(t, schema["required"].([]interface{}), "telemetry")
146+
assert.Equal(t, false, schema["additionalProperties"])
147+
assert.NotNil(t, schema["oneOf"])
148+
}
149+
95150
func TestIntentCaptureRawInputSchema(t *testing.T) {
96151
tt := testTracer(t)
97152
defer tt.Stop()

0 commit comments

Comments
 (0)