mcp: fix http-standardization (SEP-2243) (#1010)

Fixes #905
This commit is contained in:
Guglielmo Colombo 2026-06-16 11:20:53 +02:00 • committed by GitHub
commit 91c010f664
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -11,7 +11,9 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"net/http"
"strconv"
"strings"
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
@ -70,58 +72,127 @@ func unmarshalSchemaProperties(schema any) map[string]headerSchemaProperty {
return s.Properties
}
// extractParamHeaderAnnotations returns a map of parameter name to header name
// for all properties in the tool's InputSchema that have an x-mcp-header
// annotation.
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)
for propName, prop := range props {
var headerName string
if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" {
continue
}
result[propName] = headerName
}
var result []paramHeaderBinding
result = collectParamHeaderAnnotations(props, nil, result)
if len(result) == 0 {
return nil
}
return result
}
// 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 float64:
return fmt.Sprintf("%g", v), true
case bool:
return fmt.Sprintf("%t", v), true
default:
return "", false
// collectParamHeaderAnnotations walks the schema properties and records every
// 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 := 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 = append(out, paramHeaderBinding{Path: path, Header: headerName})
}
if len(prop.Properties) > 0 {
out = collectParamHeaderAnnotations(prop.Properties, path, out)
}
}
return out
}
// unmarshalPrimitive unmarshals a JSON value into a Go primitive
// (string, float64, or bool). Returns nil for non-primitive types.
// 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[path[0]]
if !ok {
return nil, false
}
for _, part := range path[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
}
// maxSafeInteger and minSafeInteger bound the integer values that can be
// 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:
//
// - 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:
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) {
@ -164,8 +235,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 _, b := range paramHeaders {
argRaw, ok := lookupArgument(raw.Arguments, b.Path)
if !ok {
continue
}
@ -180,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
}
@ -201,64 +272,84 @@ 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
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
}
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)
}
if err := validateHeaderName(headerName); err != nil {
return fmt.Errorf("property %q: %w", propName, err)
}
lower := strings.ToLower(headerName)
if seen[lower] {
return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", propName, 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)
if len(prop.Properties) > 0 {
if err := validateParamHeadersIn(prop.Properties, path, seen); err != nil {
return err
}
}
}
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
}
}
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 +406,20 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool)
return nil
}
for paramName, headerName := range paramHeaders {
fullHeader := paramHeaderPrefix + headerName
for _, b := range paramHeaders {
fullHeader := paramHeaderPrefix + b.Header
headerVal := header.Get(fullHeader)
argRaw, argExists := raw.Arguments[paramName]
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, paramName)
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, paramName)
return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, strings.Join(b.Path, "."))
}
decoded, ok := decodeHeaderValue(headerVal)
@ -338,26 +429,44 @@ 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, strings.Join(b.Path, "."))
}
// 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.
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
}
return headerStr == expected
}
// 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.
@ -383,14 +492,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 +516,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
}