diff --git a/gateway/mcp.go b/gateway/mcp.go index ecd37d700..2fb132b84 100644 --- a/gateway/mcp.go +++ b/gateway/mcp.go @@ -572,7 +572,45 @@ func projectMCPMarkedValue(value reflect.Value) (interface{}, bool) { } marker := value.FieldByName("Has") if !marker.IsValid() || marker.Kind() != reflect.Ptr || marker.IsNil() || marker.Elem().Kind() != reflect.Struct { - return mcpJSONValue(value), false + // A discriminated wrapper can contain marked patches without owning + // a marker itself. Preserve the wrapper's JSON shape while projecting + // its marked children before omitempty can erase explicit clears. + if implementsMCPJSONMarshaler(value.Interface()) { + return mcpJSONValue(value), false + } + normalized, ok := mcpJSONValue(value).(map[string]interface{}) + if !ok { + return mcpJSONValue(value), false + } + marked := false + for index := 0; index < value.NumField(); index++ { + field := value.Type().Field(index) + if !field.IsExported() { + continue + } + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "-" { + continue + } + projected, childMarked := projectMCPMarkedValue(value.Field(index)) + if !childMarked { + continue + } + if field.Anonymous && name == "" { + if embedded, ok := projected.(map[string]interface{}); ok { + for key, item := range embedded { + normalized[key] = item + } + } + } else { + if name == "" { + name = field.Name + } + normalized[name] = projected + } + marked = true + } + return normalized, marked } marker = marker.Elem() return projectMCPMarkedStruct(value, marker), true diff --git a/gateway/mcp_wrapped_presence_test.go b/gateway/mcp_wrapped_presence_test.go new file mode 100644 index 000000000..f4a3ebcfd --- /dev/null +++ b/gateway/mcp_wrapped_presence_test.go @@ -0,0 +1,36 @@ +package gateway + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMCPBodyPreservesWrappedPatchClears(t *testing.T) { + type presence struct { + ID, Tags, Nullable, Omitted bool + } + type patch struct { + ID int `json:"id"` + Tags []string `json:"tags,omitempty"` + Nullable *string `json:"nullable,omitempty"` + Omitted string `json:"omitted,omitempty"` + Has *presence `json:"-"` + } + type wrapper struct { + Kind string `json:"kind"` + Patch *patch `json:"patch,omitempty"` + } + input := []wrapper{{Kind: "native", Patch: &patch{ + ID: 7, Tags: []string{}, Has: &presence{ID: true, Tags: true, Nullable: true}, + }}} + encoded, err := json.Marshal(preserveMCPBodyPresence(nil, input)) + require.NoError(t, err) + require.JSONEq(t, `[{"kind":"native","patch":{"id":7,"tags":[],"nullable":null}}]`, string(encoded)) + // Unmarked models retain their existing omitempty behavior. + input[0].Patch.Has = nil + encoded, err = json.Marshal(preserveMCPBodyPresence(nil, input)) + require.NoError(t, err) + require.JSONEq(t, `[{"kind":"native","patch":{"id":7}}]`, string(encoded)) +}