Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions mcp/streamable_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,10 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool)
return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, strings.Join(b.Path, "."))
}

if !isHeaderSafe(headerVal) {
return fmt.Errorf("header mismatch: %s header contains invalid characters", fullHeader)
}

decoded, ok := decodeHeaderValue(headerVal)
if !ok {
return fmt.Errorf("header mismatch: %s header contains invalid Base64 encoding", fullHeader)
Expand Down Expand Up @@ -669,6 +673,18 @@ func requiresBase64Encoding(s string) bool {
return false
}

// isHeaderSafe reports whether s holds only the characters an Mcp-Param-*
// value may carry unencoded: visible ASCII, space and horizontal tab
// (RFC 9110, section 5.5). Anything else must arrive Base64-wrapped.
func isHeaderSafe(s string) bool {
for i := 0; i < len(s); i++ {
if c := s[i]; c != '\t' && (c < 0x20 || c > 0x7e) {
return false
}
}
return true
}

func encodeBase64(s string) string {
return base64Prefix + base64.StdEncoding.EncodeToString([]byte(s)) + base64Suffix
}
40 changes: 40 additions & 0 deletions mcp/streamable_headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1780,6 +1780,46 @@ func TestValidateParamHeaders_EmptyString(t *testing.T) {
}
}

// TestValidateParamHeaders_InvalidCharacters checks that a value outside
// visible ASCII is rejected unless it is Base64-wrapped, even when it equals
// the body value.
func TestValidateParamHeaders_InvalidCharacters(t *testing.T) {
tool := &Tool{
Name: "test",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"region": map[string]any{"type": "string", "x-mcp-header": "Region"},
},
},
}
tests := []struct {
name string
header string
arg string
wantErr bool
}{
{"raw non-ASCII", "café", "café", true},
{"raw control character", "a\x7fb", "a\x7fb", true},
{"Base64-wrapped non-ASCII", encodeBase64("café"), "café", false},
{"visible ASCII, space and tab", "us west\t1", "us west\t1", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
header := http.Header{}
header.Set(paramHeaderPrefix+"Region", tt.header)
msg := &jsonrpc.Request{
Method: "tools/call",
Params: mustMarshal(&CallToolParams{Name: "test", Arguments: map[string]any{"region": tt.arg}}),
}
err := validateParamHeaders(header, msg, tool)
if gotErr := err != nil; gotErr != tt.wantErr {
t.Errorf("validateParamHeaders() = %v, want error: %t", err, tt.wantErr)
}
})
}
}

// TestParamHeaderEmptyStringRoundTrip verifies that the server accepts the
// present but empty Mcp-Param header the client sends for an empty string.
func TestParamHeaderEmptyStringRoundTrip(t *testing.T) {
Expand Down
Loading