From ac6a85dd236e3485c990696acb45f46c57b0e8e9 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 28 May 2026 07:04:57 +0000 Subject: [PATCH 1/4] feat: support x-mcp-header annotations on nested object properties with improved path resolution and numerical equality checks. --- mcp/streamable_headers.go | 198 ++++++++++++++++++------- mcp/streamable_headers_test.go | 257 ++++++++++++++++++++++++++++++--- 2 files changed, 385 insertions(+), 70 deletions(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index fba1b4d9..bb6d9c29 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -11,7 +11,9 @@ import ( "errors" "fmt" "log/slog" + "math" "net/http" + "strconv" "strings" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" @@ -70,26 +72,63 @@ func unmarshalSchemaProperties(schema any) map[string]headerSchemaProperty { return s.Properties } -// extractParamHeaderAnnotations returns a map of parameter name to header name +// extractParamHeaderAnnotations returns a map of parameter path to header name // for all properties in the tool's InputSchema that have an x-mcp-header -// annotation. +// annotation, including nested properties. func extractParamHeaderAnnotations(tool *Tool) map[string]string { props := unmarshalSchemaProperties(tool.InputSchema) if len(props) == 0 { return nil } result := make(map[string]string) + collectParamHeaderAnnotations(props, "", result) + if len(result) == 0 { + return nil + } + return result +} + +// collectParamHeaderAnnotations walks the schema properties and records every +// x-mcp-header annotation it finds, keyed by the dot-separated property path. +func collectParamHeaderAnnotations(props map[string]headerSchemaProperty, prefix string, out map[string]string) { for propName, prop := range props { + path := propName + if prefix != "" { + path = prefix + "." + propName + } var headerName string - if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { - continue + if err := json.Unmarshal(prop.XMCPHeader, &headerName); err == nil && headerName != "" { + out[path] = headerName + } + if len(prop.Properties) > 0 { + collectParamHeaderAnnotations(prop.Properties, path, out) } - result[propName] = headerName } - if len(result) == 0 { - return nil +} + +// lookupArgument navigates the arguments object using a dot-separated path +// and returns the raw JSON value at that location. It reports whether the +// value was found. +func lookupArgument(args map[string]json.RawMessage, path string) (json.RawMessage, bool) { + parts := strings.Split(path, ".") + if len(parts) == 0 { + return nil, false } - return result + cur, ok := args[parts[0]] + if !ok { + return nil, false + } + for _, part := range parts[1:] { + var obj map[string]json.RawMessage + if err := internaljson.Unmarshal(cur, &obj); err != nil { + return nil, false + } + cur, ok = obj[part] + if !ok { + return nil, false + } + } + return cur, true } // primitiveToString conversion. @@ -98,8 +137,6 @@ func primitiveToString(value any) (string, bool) { switch v := value.(type) { case string: return v, true - case float64: - return fmt.Sprintf("%g", v), true case bool: return fmt.Sprintf("%t", v), true default: @@ -164,8 +201,8 @@ func generateParamHeaders(tool *Tool, params json.RawMessage) map[string]string } res := make(map[string]string) - for paramName, headerName := range paramHeaders { - argRaw, ok := raw.Arguments[paramName] + for paramPath, headerName := range paramHeaders { + argRaw, ok := lookupArgument(raw.Arguments, paramPath) if !ok { continue } @@ -201,64 +238,85 @@ func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool { } // validateParamHeaderAnnotations checks that a tool's x-mcp-header annotations -// are valid. +// are valid. Annotations may appear on properties at any nesting +// depth within the inputSchema and must be unique across all of them. func validateParamHeaderAnnotations(tool *Tool) error { props := unmarshalSchemaProperties(tool.InputSchema) if len(props) == 0 { return nil } - seen := make(map[string]bool) + return validateParamHeadersIn(props, "", seen) +} + +func validateParamHeadersIn(props map[string]headerSchemaProperty, prefix string, seen map[string]bool) error { for propName, prop := range props { - if err := checkForNestedHeaders(prop, propName); err != nil { - return err + path := propName + if prefix != "" { + path = prefix + "." + propName } if prop.XMCPHeader == nil { continue } var headerName string if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { - return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", propName) + return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", path) } if err := validateHeaderName(headerName); err != nil { - return fmt.Errorf("property %q: %w", propName, err) + return fmt.Errorf("property %q: %w", path, err) } lower := strings.ToLower(headerName) if seen[lower] { - return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", propName, headerName) + return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", path, headerName) } seen[lower] = true - if prop.Type != "string" && prop.Type != "number" && prop.Type != "integer" && prop.Type != "boolean" { - return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types, got %v", propName, prop.Type) + return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types, got %v", path, prop.Type) } - } - return nil -} - -func checkForNestedHeaders(prop headerSchemaProperty, path string) error { - for propName, nested := range prop.Properties { - if nested.XMCPHeader != nil { - return fmt.Errorf("property %q: x-mcp-header cannot be applied to nested properties", path+"."+propName) - } - if err := checkForNestedHeaders(nested, path+"."+propName); err != nil { - return err + if len(prop.Properties) > 0 { + if err := validateParamHeadersIn(prop.Properties, path, seen); err != nil { + return err + } } } return nil } -// validateHeaderName checks that a header name contains only valid -// ASCII characters (excluding space and ':'). +// validateHeaderName checks that a header name matches the HTTP field-name +// token syntax (1*tchar). func validateHeaderName(name string) error { + if name == "" { + return fmt.Errorf("x-mcp-header value must be a non-empty string") + } for _, c := range name { - if c <= 0x20 || c > 0x7E || c == ':' { + if !isTChar(c) { return fmt.Errorf("x-mcp-header value %q contains invalid character %q", name, c) } } return nil } +// isTChar reports whether c is a valid HTTP token character (tchar) +// +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / +// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +func isTChar(c rune) bool { + switch { + case c >= '0' && c <= '9': + return true + case c >= 'A' && c <= 'Z': + return true + case c >= 'a' && c <= 'z': + return true + } + switch c { + case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', + '^', '_', '`', '|', '~': + return true + } + return false +} + func validateMcpHeaders(header http.Header, msg jsonrpc.Message, toolLookup func(string) (*serverTool, bool)) error { protocolVersion := header.Get(protocolVersionHeader) if protocolVersion == "" || protocolVersion < minVersionForStandardHeaders { @@ -315,20 +373,20 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) return nil } - for paramName, headerName := range paramHeaders { + for paramPath, headerName := range paramHeaders { fullHeader := paramHeaderPrefix + headerName headerVal := header.Get(fullHeader) - argRaw, argExists := raw.Arguments[paramName] + argRaw, argExists := lookupArgument(raw.Arguments, paramPath) if !argExists || string(argRaw) == "null" { if headerVal != "" { - return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, paramName) + return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, paramPath) } continue } if headerVal == "" { - return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, paramName) + return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, paramPath) } decoded, ok := decodeHeaderValue(headerVal) @@ -338,21 +396,50 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) bodyVal := unmarshalPrimitive(argRaw) if bodyVal == nil { - return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, paramName) - } - expected, ok := primitiveToString(bodyVal) - if !ok { - return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, paramName) + return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, paramPath) } - // TODO: String comparison may not work ideally for numbers - if decoded != expected { + if !primitiveEqual(decoded, bodyVal) { return fmt.Errorf("header mismatch: %s header value '%s' does not match body value", fullHeader, headerVal) } } return nil } +// primitiveEqual reports whether the (decoded) header string equals the +// JSON-derived body value. For numeric body values, the comparison is done +// numerically with a relative precision of 1e-9. +// For other primitive types, comparison is done as strings. +func primitiveEqual(headerStr string, bodyVal any) bool { + if bodyNum, ok := bodyVal.(float64); ok { + headerNum, err := strconv.ParseFloat(headerStr, 64) + if err != nil { + return false + } + return numericallyEqual(headerNum, bodyNum) + } + expected, ok := primitiveToString(bodyVal) + if !ok { + return false + } + return headerStr == expected +} + +// numericallyEqual reports whether two float64 values are equal within a +// relative precision of 1e-9 (with an absolute fallback for values near zero). +func numericallyEqual(a, b float64) bool { + if a == b { + return true + } + const epsilon = 1e-9 + diff := math.Abs(a - b) + largest := math.Max(math.Abs(a), math.Abs(b)) + if largest < 1 { + return diff <= epsilon + } + return diff <= epsilon*largest +} + // encodeHeaderValue converts a parameter value to an HTTP header-safe string // per the SEP-2243 encoding rules: // - string: used as-is if safe ASCII, otherwise Base64 encoded @@ -383,14 +470,14 @@ func decodeHeaderValue(headerValue string) (string, bool) { return headerValue, true } - if strings.HasPrefix(strings.ToLower(headerValue), base64Prefix) && - strings.HasSuffix(headerValue, base64Suffix) { - encoded := headerValue[len(base64Prefix) : len(headerValue)-len(base64Suffix)] - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return "", false + if encoded, ok := strings.CutPrefix(headerValue, base64Prefix); ok { + if encoded, ok = strings.CutSuffix(encoded, base64Suffix); ok { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", false + } + return string(decoded), true } - return string(decoded), true } return headerValue, true } @@ -407,6 +494,11 @@ func requiresBase64Encoding(s string) bool { return true } } + // Per SEP-2243, plain-ASCII values that match the base64 sentinel pattern + // must also be base64-encoded to avoid ambiguity with already-encoded values. + if strings.HasPrefix(s, base64Prefix) && strings.HasSuffix(s, base64Suffix) { + return true + } return false } diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 88e9f467..64272dfe 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -519,6 +519,55 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErr: true, wantErrSub: "invalid character", }, + { + name: "x-mcp-header with separator char (parens) is invalid per RFC 9110", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "X-(Region)", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with equals sign is invalid per RFC 9110", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region=1", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with all tchar specials is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "!#$%&'*+-.^_`|~aZ0", + }, + }, + }, + }, + }, { name: "duplicate header names same case", tool: &Tool{ @@ -629,7 +678,7 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, { - name: "x-mcp-header on nested property inside object", + name: "x-mcp-header on nested property inside object is valid", tool: &Tool{ Name: "test", InputSchema: map[string]any{ @@ -647,11 +696,9 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, }, - wantErr: true, - wantErrSub: "nested", }, { - name: "x-mcp-header on deeply nested property", + name: "x-mcp-header on deeply nested property is valid", tool: &Tool{ Name: "test", InputSchema: map[string]any{ @@ -674,8 +721,26 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, }, + }, + { + name: "duplicate header names across nesting levels", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + }, wantErr: true, - wantErrSub: "nested", + wantErrSub: "duplicate", }, { name: "object property without nested x-mcp-header is valid", @@ -733,7 +798,7 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErrSub: "primitive types", }, { - name: "jsonschema.Schema nested x-mcp-header", + name: "jsonschema.Schema nested x-mcp-header is valid", tool: &Tool{ Name: "test", InputSchema: &jsonschema.Schema{ @@ -751,8 +816,6 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, }, - wantErr: true, - wantErrSub: "nested", }, { name: "json.RawMessage valid x-mcp-header", @@ -812,15 +875,15 @@ func TestFilterValidTools(t *testing.T) { Name: "plain", InputSchema: map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}}, } - nestedInvalid := &Tool{ - Name: "nested-invalid", + nestedValid := &Tool{ + Name: "nested-valid", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "config": map[string]any{ "type": "object", "properties": map[string]any{ - "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, }, }, }, @@ -852,12 +915,13 @@ func TestFilterValidTools(t *testing.T) { }, } - result := filterValidTools(nil, []*Tool{valid, invalid, noAnnotation, nestedInvalid, validJsonSchema, invalidJsonSchema}) - if len(result) != 3 { - t.Fatalf("filterValidTools returned %d tools, want 3", len(result)) + result := filterValidTools(nil, []*Tool{valid, invalid, noAnnotation, nestedValid, validJsonSchema, invalidJsonSchema}) + if len(result) != 4 { + t.Fatalf("filterValidTools returned %d tools, want 4", len(result)) } - if result[0].Name != "valid" || result[1].Name != "plain" || result[2].Name != "valid-jsonschema" { - t.Errorf("filterValidTools returned [%s, %s, %s], want [valid, plain, valid-jsonschema]", result[0].Name, result[1].Name, result[2].Name) + if result[0].Name != "valid" || result[1].Name != "plain" || result[2].Name != "nested-valid" || result[3].Name != "valid-jsonschema" { + t.Errorf("filterValidTools returned [%s, %s, %s, %s], want [valid, plain, nested-valid, valid-jsonschema]", + result[0].Name, result[1].Name, result[2].Name, result[3].Name) } } @@ -974,6 +1038,37 @@ func TestSetStandardHeadersWithParamHeaders(t *testing.T) { }, wantHeaders: nil, }, + { + name: "nested arguments resolve via dotted path", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + }, + }, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{ + "config": map[string]any{ + "region": "us-west1", + "tenant": "acme", + }, + }, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Region": "us-west1", + "Mcp-Param-TenantId": "acme", + }, + }, } for _, tt := range tests { @@ -1090,6 +1185,35 @@ func TestExtractToolParamHeaders(t *testing.T) { }, want: nil, }, + { + name: "nested x-mcp-header annotations use dotted paths", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + "deep": map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{"type": "boolean", "x-mcp-header": "DeepFlag"}, + }, + }, + }, + }, + }, + }, + }, + want: map[string]string{ + "region": "Region", + "config.tenant": "TenantId", + "config.deep.flag": "DeepFlag", + }, + }, } for _, tt := range tests { @@ -1158,6 +1282,13 @@ func TestEncodeHeaderValue(t *testing.T) { {"string with carriage return", "line1\r\nline2", "=?base64?bGluZTENCmxpbmUy?=", true}, {"string with leading tab", "\tindented", "=?base64?CWluZGVudGVk?=", true}, + // Sentinel pattern collisions: plain-ASCII values that match the base64 + // sentinel pattern must also be base64-encoded to avoid ambiguity. + {"sentinel collision literal", "=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", true}, + {"sentinel collision empty", "=?base64??=", "=?base64?PT9iYXNlNjQ/Pz0=?=", true}, + // Uppercase sentinel does NOT collide (case-sensitive markers). + {"uppercase pseudo-sentinel passes through", "=?BASE64?abc?=", "=?BASE64?abc?=", true}, + // Numbers {"integer", float64(42), "42", true}, {"float", float64(3.14159), "3.14159", true}, @@ -1196,7 +1327,10 @@ func TestDecodeHeaderValue(t *testing.T) { {"valid base64", "=?base64?SGVsbG8=?=", "Hello", true}, {"non-ASCII decoded", "=?base64?5pel5pys6Kqe?=", "日本語", true}, {"leading space decoded", "=?base64?IHVzLXdlc3Qx?=", " us-west1", true}, - {"case-insensitive prefix", "=?BASE64?SGVsbG8=?=", "Hello", true}, + // Per SEP-2243, the base64 sentinel markers are case-sensitive: an + // uppercase prefix is treated as a literal value, not a base64 marker. + {"uppercase prefix is literal", "=?BASE64?SGVsbG8=?=", "=?BASE64?SGVsbG8=?=", true}, + {"mixed case prefix is literal", "=?Base64?SGVsbG8=?=", "=?Base64?SGVsbG8=?=", true}, {"invalid base64 chars", "=?base64?SGVs!!!bG8=?=", "", false}, // Missing prefix or suffix: treated as literal values, not base64 {"missing prefix", "SGVsbG8=", "SGVsbG8=", true}, @@ -1225,6 +1359,7 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { "Hello, 世界", "line1\nline2", "\ttab", + "=?base64?literal?=", // sentinel-pattern collision (SEP-2243) } for _, v := range values { encoded, ok := encodeHeaderValue(v) @@ -1240,3 +1375,91 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { } } } + +// TestValidateParamHeaders_NumericComparison verifies that numeric values are +// compared as numbers (per SEP-2243), so common representational differences +// like "42" vs "42.0" or "1e2" vs "100" do not cause spurious mismatches. +func TestValidateParamHeaders_NumericComparison(t *testing.T) { + tool := &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "number", "x-mcp-header": "Count"}, + }, + }, + } + tests := []struct { + name string + headerVal string + bodyArg any + wantErr bool + }{ + {"integer matches integer", "42", float64(42), false}, + {"integer header matches float body", "42", float64(42.0), false}, + {"float header matches integer body", "42.0", float64(42), false}, + {"scientific notation matches integer", "1e2", float64(100), false}, + {"integer matches scientific notation body", "100", float64(1e2), false}, + {"negative numbers match", "-3.14", float64(-3.14), false}, + {"different numbers do not match", "42", float64(43), true}, + {"non-numeric header fails", "not-a-number", float64(42), true}, + {"large integers match exactly", "1000000000000", float64(1e12), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(paramHeaderPrefix+"Count", tt.headerVal) + args := map[string]any{"count": tt.bodyArg} + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(&CallToolParams{Name: "test", Arguments: args}), + } + err := validateParamHeaders(header, msg, tool) + if tt.wantErr && err == nil { + t.Errorf("validateParamHeaders() = nil, want error") + } + if !tt.wantErr && err != nil { + t.Errorf("validateParamHeaders() = %v, want nil", err) + } + }) + } +} + +// TestValidateParamHeaders_NestedArguments verifies that the server-side +// validation can look up nested arguments via the dotted path produced by +// extractParamHeaderAnnotations. +func TestValidateParamHeaders_NestedArguments(t *testing.T) { + tool := &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + } + header := http.Header{} + header.Set(paramHeaderPrefix+"Region", "us-west1") + args := map[string]any{ + "config": map[string]any{"region": "us-west1"}, + } + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(&CallToolParams{Name: "test", Arguments: args}), + } + if err := validateParamHeaders(header, msg, tool); err != nil { + t.Errorf("validateParamHeaders() = %v, want nil", err) + } + + // Mismatched nested value should fail. + args["config"].(map[string]any)["region"] = "eu-west1" + msg.Params = mustMarshal(&CallToolParams{Name: "test", Arguments: args}) + if err := validateParamHeaders(header, msg, tool); err == nil { + t.Error("validateParamHeaders() = nil, want error for mismatched nested value") + } +} From ebd6e5ff568fcd0c73799c57de4bae6dbc54d5ec Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 8 Jun 2026 12:43:47 +0000 Subject: [PATCH 2/4] refactor: enforce SEP-2243 integer constraints for x-mcp-headers and improve validation logic --- mcp/streamable_headers.go | 129 +++++++++++++++++---------------- mcp/streamable_headers_test.go | 120 ++++++++++++++++++++++++------ 2 files changed, 167 insertions(+), 82 deletions(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index bb6d9c29..7cb3f72b 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -131,34 +131,66 @@ func lookupArgument(args map[string]json.RawMessage, path string) (json.RawMessa return cur, true } -// primitiveToString conversion. -// Returns false in the second return value if the argument is not a primitive value. -func primitiveToString(value any) (string, bool) { - switch v := value.(type) { - case string: - return v, true - case bool: - return fmt.Sprintf("%t", v), true - default: - return "", false - } -} +// maxSafeInteger and minSafeInteger bound the integer values that can be +// faithfully represented as IEEE-754 double-precision floats. Per SEP-2243 +// (PR #2772), x-mcp-header integer parameter values MUST fall within this +// range. +const ( + maxSafeInteger = 1<<53 - 1 // 2^53 - 1 = 9007199254740991 + minSafeInteger = -(1<<53 - 1) // -(2^53 - 1) = -9007199254740991 +) -// unmarshalPrimitive unmarshals a JSON value into a Go primitive -// (string, float64, or bool). Returns nil for non-primitive types. +// unmarshalPrimitive unmarshals a JSON value into the Go representation used +// for x-mcp-header processing per SEP-2243 (PR #2772): +// +// - JSON string -> string +// - JSON boolean -> bool +// - JSON integer (within the JavaScript safe-integer range) -> int64 +// +// JSON numbers that are non-integers (have a fractional part, NaN, or ±Inf) +// or integers outside the safe range are rejected because the `number` type +// is not permitted for x-mcp-header parameters; only integer, string, boolean +// are allowed. func unmarshalPrimitive(raw json.RawMessage) any { var val any if err := internaljson.Unmarshal(raw, &val); err != nil { return nil } - switch val.(type) { - case string, float64, bool: - return val + switch v := val.(type) { + case string, bool: + return v + case float64: + // JSON numbers always decode as float64; promote to int64 if the + // value is an integer within the safe range, otherwise reject. + if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { + return nil + } + if v < minSafeInteger || v > maxSafeInteger { + return nil + } + return int64(v) default: return nil } } +// primitiveToString formats an x-mcp-header value (as produced by +// [unmarshalPrimitive]) to its canonical header string representation per +// SEP-2243. Returns false if value is not one of the permitted primitive +// types (string, bool, int64). +func primitiveToString(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case bool: + return fmt.Sprintf("%t", v), true + case int64: + return strconv.FormatInt(v, 10), true + default: + return "", false + } +} + // setStandardHeaders populates standard MCP headers. // It requires the protocol version header to be set. func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Message) { @@ -255,23 +287,22 @@ func validateParamHeadersIn(props map[string]headerSchemaProperty, prefix string if prefix != "" { path = prefix + "." + propName } - if prop.XMCPHeader == nil { - continue - } - var headerName string - if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { - return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", path) - } - if err := validateHeaderName(headerName); err != nil { - return fmt.Errorf("property %q: %w", path, err) - } - lower := strings.ToLower(headerName) - if seen[lower] { - return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", path, headerName) - } - seen[lower] = true - if prop.Type != "string" && prop.Type != "number" && prop.Type != "integer" && prop.Type != "boolean" { - return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types, got %v", path, prop.Type) + if prop.XMCPHeader != nil { + if prop.Type != "string" && prop.Type != "integer" && prop.Type != "boolean" { + return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types (integer, string, boolean), got %q", path, prop.Type) + } + var headerName string + if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { + return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", path) + } + if err := validateHeaderName(headerName); err != nil { + return fmt.Errorf("property %q: %w", path, err) + } + lower := strings.ToLower(headerName) + if seen[lower] { + return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", path, headerName) + } + seen[lower] = true } if len(prop.Properties) > 0 { if err := validateParamHeadersIn(prop.Properties, path, seen); err != nil { @@ -407,17 +438,8 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) } // primitiveEqual reports whether the (decoded) header string equals the -// JSON-derived body value. For numeric body values, the comparison is done -// numerically with a relative precision of 1e-9. -// For other primitive types, comparison is done as strings. +// JSON-derived body value. func primitiveEqual(headerStr string, bodyVal any) bool { - if bodyNum, ok := bodyVal.(float64); ok { - headerNum, err := strconv.ParseFloat(headerStr, 64) - if err != nil { - return false - } - return numericallyEqual(headerNum, bodyNum) - } expected, ok := primitiveToString(bodyVal) if !ok { return false @@ -425,26 +447,11 @@ func primitiveEqual(headerStr string, bodyVal any) bool { return headerStr == expected } -// numericallyEqual reports whether two float64 values are equal within a -// relative precision of 1e-9 (with an absolute fallback for values near zero). -func numericallyEqual(a, b float64) bool { - if a == b { - return true - } - const epsilon = 1e-9 - diff := math.Abs(a - b) - largest := math.Max(math.Abs(a), math.Abs(b)) - if largest < 1 { - return diff <= epsilon - } - return diff <= epsilon*largest -} - // encodeHeaderValue converts a parameter value to an HTTP header-safe string // per the SEP-2243 encoding rules: // - string: used as-is if safe ASCII, otherwise Base64 encoded -// - number (float64): decimal string representation -// - bool: lowercase "true" or "false" +// - int64: decimal string representation +// - bool: lowercase "true" or "false" // // Values that contain non-ASCII characters, control characters, or // leading/trailing whitespace are Base64-encoded with the =?base64?...?= wrapper. diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 64272dfe..53709f1e 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -633,7 +633,7 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErrSub: "primitive types", }, { - name: "x-mcp-header on number type is valid", + name: "x-mcp-header on number type is invalid", tool: &Tool{ Name: "test", InputSchema: map[string]any{ @@ -646,6 +646,8 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, }, + wantErr: true, + wantErrSub: "primitive types", }, { name: "x-mcp-header on integer type is valid", @@ -833,6 +835,50 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErr: true, wantErrSub: "primitive types", }, + { + name: "nested invalid header name is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Bad Header", // contains a space + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "nested x-mcp-header on number type is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{ + "type": "number", + "x-mcp-header": "Count", + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, } for _, tt := range tests { @@ -1011,13 +1057,13 @@ func TestSetStandardHeadersWithParamHeaders(t *testing.T) { }, }, { - name: "handles number argument", + name: "handles integer argument", tool: &Tool{ Name: "test", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ - "count": map[string]any{"type": "number", "x-mcp-header": "Count"}, + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, }, }, }, @@ -1029,6 +1075,23 @@ func TestSetStandardHeadersWithParamHeaders(t *testing.T) { "Mcp-Param-Count": "42", }, }, + { + name: "out-of-range integer argument produces no header", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{"count": float64(maxSafeInteger) + 2}, + }, + wantHeaders: nil, + }, { name: "no tool in extra does not add param headers", tool: nil, @@ -1244,10 +1307,24 @@ func TestUnmarshalPrimitive(t *testing.T) { want any }{ {"string", `"hello"`, "hello"}, - {"number", `42`, float64(42)}, - {"float", `3.14`, float64(3.14)}, {"true", `true`, true}, {"false", `false`, false}, + + // Integer JSON numbers are promoted to int64. + {"integer", `42`, int64(42)}, + {"integer zero", `0`, int64(0)}, + {"integer negative", `-7`, int64(-7)}, + {"integer max safe", `9007199254740991`, int64(maxSafeInteger)}, + {"integer min safe", `-9007199254740991`, int64(minSafeInteger)}, + // JSON serialization of an integer-valued float (e.g. "42.0") is + // still a valid integer at the value level and must be accepted. + {"integer-valued float", `42.0`, int64(42)}, + + // Non-integer numbers, out-of-range integers, and disallowed JSON + // kinds are rejected (return nil). + {"float with fraction", `3.14`, nil}, + {"integer above max safe", `9007199254740993`, nil}, + {"integer below min safe", `-9007199254740993`, nil}, {"null", `null`, nil}, {"array", `[1,2]`, nil}, {"object", `{"a":1}`, nil}, @@ -1289,15 +1366,17 @@ func TestEncodeHeaderValue(t *testing.T) { // Uppercase sentinel does NOT collide (case-sensitive markers). {"uppercase pseudo-sentinel passes through", "=?BASE64?abc?=", "=?BASE64?abc?=", true}, - // Numbers - {"integer", float64(42), "42", true}, - {"float", float64(3.14159), "3.14159", true}, + {"integer", int64(42), "42", true}, + {"integer zero", int64(0), "0", true}, + {"integer negative", int64(-7), "-7", true}, + {"integer max safe", int64(maxSafeInteger), "9007199254740991", true}, + {"integer min safe", int64(minSafeInteger), "-9007199254740991", true}, // Booleans {"true", true, "true", true}, {"false", false, "false", true}, - // Unsupported types + {"raw float64 rejected", float64(42), "", false}, {"nil", nil, "", false}, {"slice", []string{"a"}, "", false}, } @@ -1376,16 +1455,15 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { } } -// TestValidateParamHeaders_NumericComparison verifies that numeric values are -// compared as numbers (per SEP-2243), so common representational differences -// like "42" vs "42.0" or "1e2" vs "100" do not cause spurious mismatches. -func TestValidateParamHeaders_NumericComparison(t *testing.T) { +// TestValidateParamHeaders_IntegerComparison verifies server-side validation +// of integer x-mcp-header parameters per SEP-2243. +func TestValidateParamHeaders_IntegerComparison(t *testing.T) { tool := &Tool{ Name: "test", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ - "count": map[string]any{"type": "number", "x-mcp-header": "Count"}, + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, }, }, } @@ -1396,14 +1474,14 @@ func TestValidateParamHeaders_NumericComparison(t *testing.T) { wantErr bool }{ {"integer matches integer", "42", float64(42), false}, - {"integer header matches float body", "42", float64(42.0), false}, - {"float header matches integer body", "42.0", float64(42), false}, - {"scientific notation matches integer", "1e2", float64(100), false}, - {"integer matches scientific notation body", "100", float64(1e2), false}, - {"negative numbers match", "-3.14", float64(-3.14), false}, - {"different numbers do not match", "42", float64(43), true}, + {"integer header matches integer-valued float body", "42", float64(42.0), false}, + {"negative integer matches", "-7", float64(-7), false}, + {"large safe integer matches", "1000000000000", float64(1e12), false}, + {"non-canonical '42.0' rejected against integer body", "42.0", float64(42), true}, + {"scientific notation rejected", "1e2", float64(100), true}, + {"non-integer body rejected (number type not permitted)", "3.14", float64(3.14), true}, + {"different integers do not match", "42", float64(43), true}, {"non-numeric header fails", "not-a-number", float64(42), true}, - {"large integers match exactly", "1000000000000", float64(1e12), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 69db6a21e02510ce4c87afdf63965ed1ffecdcdb Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 8 Jun 2026 13:25:13 +0000 Subject: [PATCH 3/4] refactor: update primitive comparison to support integer-valued header strings against int64 body types --- mcp/streamable_headers.go | 21 +++++++++++++++------ mcp/streamable_headers_test.go | 12 +++++++++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 7cb3f72b..287ec0b8 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -132,16 +132,14 @@ func lookupArgument(args map[string]json.RawMessage, path string) (json.RawMessa } // maxSafeInteger and minSafeInteger bound the integer values that can be -// faithfully represented as IEEE-754 double-precision floats. Per SEP-2243 -// (PR #2772), x-mcp-header integer parameter values MUST fall within this -// range. +// faithfully represented as IEEE-754 double-precision floats. const ( maxSafeInteger = 1<<53 - 1 // 2^53 - 1 = 9007199254740991 minSafeInteger = -(1<<53 - 1) // -(2^53 - 1) = -9007199254740991 ) // unmarshalPrimitive unmarshals a JSON value into the Go representation used -// for x-mcp-header processing per SEP-2243 (PR #2772): +// for x-mcp-header processing per SEP-2243: // // - JSON string -> string // - JSON boolean -> bool @@ -160,8 +158,6 @@ func unmarshalPrimitive(raw json.RawMessage) any { case string, bool: return v case float64: - // JSON numbers always decode as float64; promote to int64 if the - // value is an integer within the safe range, otherwise reject. if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { return nil } @@ -440,6 +436,19 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) // primitiveEqual reports whether the (decoded) header string equals the // JSON-derived body value. func primitiveEqual(headerStr string, bodyVal any) bool { + if bodyInt, ok := bodyVal.(int64); ok { + headerNum, err := strconv.ParseFloat(headerStr, 64) + if err != nil { + return false + } + if math.IsNaN(headerNum) || math.IsInf(headerNum, 0) || headerNum != math.Trunc(headerNum) { + return false + } + if headerNum < minSafeInteger || headerNum > maxSafeInteger { + return false + } + return int64(headerNum) == bodyInt + } expected, ok := primitiveToString(bodyVal) if !ok { return false diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 53709f1e..f1c75f87 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -1473,15 +1473,21 @@ func TestValidateParamHeaders_IntegerComparison(t *testing.T) { bodyArg any wantErr bool }{ + // Canonical decimal form matches. {"integer matches integer", "42", float64(42), false}, {"integer header matches integer-valued float body", "42", float64(42.0), false}, {"negative integer matches", "-7", float64(-7), false}, {"large safe integer matches", "1000000000000", float64(1e12), false}, - {"non-canonical '42.0' rejected against integer body", "42.0", float64(42), true}, - {"scientific notation rejected", "1e2", float64(100), true}, - {"non-integer body rejected (number type not permitted)", "3.14", float64(3.14), true}, + + {"non-canonical '42.0' header matches integer body", "42.0", float64(42), false}, + {"scientific notation header matches integer body", "1e2", float64(100), false}, + {"negative non-canonical header matches", "-7.0", float64(-7), false}, + + // Genuine mismatches and invalid forms are still rejected. {"different integers do not match", "42", float64(43), true}, + {"fractional header against integer body", "3.14", float64(3), true}, {"non-numeric header fails", "not-a-number", float64(42), true}, + {"header outside safe range against integer body", "9007199254740993", float64(42), true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 2fab6fa9d317fc6f462e4c3d5ba2b746de7942bb Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Mon, 15 Jun 2026 17:27:46 +0000 Subject: [PATCH 4/4] refactor: replace dot-separated path strings with path slices in header annotations to support property names containing dots --- mcp/streamable_headers.go | 68 ++++++++++++++++++---------------- mcp/streamable_headers_test.go | 25 +++++++++++-- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 287ec0b8..af9ff396 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -72,16 +72,22 @@ func unmarshalSchemaProperties(schema any) map[string]headerSchemaProperty { return s.Properties } -// extractParamHeaderAnnotations returns a map of parameter path to header name -// for all properties in the tool's InputSchema that have an x-mcp-header -// annotation, including nested properties. -func extractParamHeaderAnnotations(tool *Tool) map[string]string { +// paramHeaderBinding maps a (possibly nested) input-schema property to the +// HTTP header it carries. +type paramHeaderBinding struct { + Path []string + Header string +} + +// extractParamHeaderAnnotations returns the bindings for every property in +// the tool's InputSchema that has an x-mcp-header annotation +func extractParamHeaderAnnotations(tool *Tool) []paramHeaderBinding { props := unmarshalSchemaProperties(tool.InputSchema) if len(props) == 0 { return nil } - result := make(map[string]string) - collectParamHeaderAnnotations(props, "", result) + var result []paramHeaderBinding + result = collectParamHeaderAnnotations(props, nil, result) if len(result) == 0 { return nil } @@ -89,36 +95,36 @@ func extractParamHeaderAnnotations(tool *Tool) map[string]string { } // collectParamHeaderAnnotations walks the schema properties and records every -// x-mcp-header annotation it finds, keyed by the dot-separated property path. -func collectParamHeaderAnnotations(props map[string]headerSchemaProperty, prefix string, out map[string]string) { +// x-mcp-header annotation it finds, keyed by the property-name path. +func collectParamHeaderAnnotations(props map[string]headerSchemaProperty, prefix []string, out []paramHeaderBinding) []paramHeaderBinding { for propName, prop := range props { - path := propName - if prefix != "" { - path = prefix + "." + propName - } + path := make([]string, len(prefix)+1) + copy(path, prefix) + path[len(prefix)] = propName + var headerName string if err := json.Unmarshal(prop.XMCPHeader, &headerName); err == nil && headerName != "" { - out[path] = headerName + out = append(out, paramHeaderBinding{Path: path, Header: headerName}) } if len(prop.Properties) > 0 { - collectParamHeaderAnnotations(prop.Properties, path, out) + out = collectParamHeaderAnnotations(prop.Properties, path, out) } } + return out } -// lookupArgument navigates the arguments object using a dot-separated path -// and returns the raw JSON value at that location. It reports whether the -// value was found. -func lookupArgument(args map[string]json.RawMessage, path string) (json.RawMessage, bool) { - parts := strings.Split(path, ".") - if len(parts) == 0 { +// lookupArgument navigates the arguments object using the given property-name +// path and returns the raw JSON value at that location. It reports whether +// the value was found. +func lookupArgument(args map[string]json.RawMessage, path []string) (json.RawMessage, bool) { + if len(path) == 0 { return nil, false } - cur, ok := args[parts[0]] + cur, ok := args[path[0]] if !ok { return nil, false } - for _, part := range parts[1:] { + for _, part := range path[1:] { var obj map[string]json.RawMessage if err := internaljson.Unmarshal(cur, &obj); err != nil { return nil, false @@ -229,8 +235,8 @@ func generateParamHeaders(tool *Tool, params json.RawMessage) map[string]string } res := make(map[string]string) - for paramPath, headerName := range paramHeaders { - argRaw, ok := lookupArgument(raw.Arguments, paramPath) + for _, b := range paramHeaders { + argRaw, ok := lookupArgument(raw.Arguments, b.Path) if !ok { continue } @@ -245,7 +251,7 @@ func generateParamHeaders(tool *Tool, params json.RawMessage) map[string]string if !ok { continue } - res[paramHeaderPrefix+headerName] = encoded + res[paramHeaderPrefix+b.Header] = encoded } return res } @@ -400,20 +406,20 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) return nil } - for paramPath, headerName := range paramHeaders { - fullHeader := paramHeaderPrefix + headerName + for _, b := range paramHeaders { + fullHeader := paramHeaderPrefix + b.Header headerVal := header.Get(fullHeader) - argRaw, argExists := lookupArgument(raw.Arguments, paramPath) + argRaw, argExists := lookupArgument(raw.Arguments, b.Path) if !argExists || string(argRaw) == "null" { if headerVal != "" { - return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, paramPath) + return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, strings.Join(b.Path, ".")) } continue } if headerVal == "" { - return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, paramPath) + return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, strings.Join(b.Path, ".")) } decoded, ok := decodeHeaderValue(headerVal) @@ -423,7 +429,7 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) bodyVal := unmarshalPrimitive(argRaw) if bodyVal == nil { - return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, paramPath) + return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, strings.Join(b.Path, ".")) } if !primitiveEqual(decoded, bodyVal) { diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index f1c75f87..52d52447 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -1249,7 +1249,7 @@ func TestExtractToolParamHeaders(t *testing.T) { want: nil, }, { - name: "nested x-mcp-header annotations use dotted paths", + name: "nested x-mcp-header annotations produce path-slice bindings", tool: &Tool{ Name: "test", InputSchema: map[string]any{ @@ -1277,6 +1277,19 @@ func TestExtractToolParamHeaders(t *testing.T) { "config.deep.flag": "DeepFlag", }, }, + { + name: "property name containing a dot is preserved in path", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "user.id": map[string]any{"type": "string", "x-mcp-header": "UserId"}, + }, + }, + }, + want: map[string]string{"user.id": "UserId"}, + }, } for _, tt := range tests { @@ -1291,9 +1304,15 @@ func TestExtractToolParamHeaders(t *testing.T) { if len(got) != len(tt.want) { t.Fatalf("extractToolParamHeaders() returned %d entries, want %d", len(got), len(tt.want)) } + // Index returned bindings by joined-path for comparison; the + // expected map uses dotted-path keys for readability. + gotMap := make(map[string]string, len(got)) + for _, b := range got { + gotMap[strings.Join(b.Path, ".")] = b.Header + } for k, v := range tt.want { - if got[k] != v { - t.Errorf("extractToolParamHeaders()[%q] = %q, want %q", k, got[k], v) + if gotMap[k] != v { + t.Errorf("extractToolParamHeaders()[%q] = %q, want %q", k, gotMap[k], v) } } })