mcp: base64-encode a non-header-safe Mcp-Name (#1246)
Fixes: https://github.com/modelcontextprotocol/go-sdk/issues/1243 The 2026-07-28 transport spec only SHOULD-constrains names to header-safe characters, and lets a client Base64-wrap one that is not, using the `=?base64?...?=` sentinel. `setStandardHeaders` set `Mcp-Name` raw, while the sibling `Mcp-Param-*` path already ran its values through `encodeHeaderValue`, so a name outside the safe set went out unencoded. This routes `Mcp-Name` through `encodeHeaderValue` as well. #1242 landed the other direction — the server decoding the sentinel before comparing — so the two together close the round trip. Worth noting they interact: after #1242, a name that is literally shaped like the sentinel (`=?base64?abc?=`) is now rejected as invalid Base64, because the server tries to decode a value the client never encoded. The new test covers that case, and it fails on `main` today. `extractName` also covers `resources/read`, so this applies to resource URIs, which is where non-ASCII is most likely to show up in practice. ## Testing `TestMcpNameHeaderEncodingRoundTrip` drives `setStandardHeaders` and then feeds its output to `validateMcpHeaders`, so it pins the client and server halves against each other rather than asserting the encoding in isolation. Cases: an ASCII-safe name (must stay unencoded, no churn for the common path), non-ASCII, a leading space, a control character, and the sentinel-shaped literal above. Verified failing before the change and passing after. `go test ./...` passes. A note on process: [@remote-controlled-man](https://github.com/remote-controlled-man) asked about this issue earlier today and proposed much the same scope. I had the change written before I saw that comment and thought the #1242 interaction was worth surfacing either way, but I'm happy to close this if they'd rather carry it — please just say so. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011Wf643Lx36V5wdwuuf2EdT
This commit is contained in:
parent
a5026ea7b7
commit
f99cdb649b
2 changed files with 60 additions and 1 deletions
|
|
@ -207,7 +207,12 @@ func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Mes
|
|||
case *jsonrpc.Request:
|
||||
header.Set(methodHeader, msg.Method)
|
||||
if name, ok := extractName(msg.Method, msg.Params); ok {
|
||||
header.Set(nameHeader, name)
|
||||
// Names are only SHOULD-constrained to header-safe characters, so a
|
||||
// name outside the safe set is Base64-wrapped with the =?base64?...?=
|
||||
// sentinel, the same rule applied to Mcp-Param-* values.
|
||||
if encoded, ok := encodeHeaderValue(name); ok {
|
||||
header.Set(nameHeader, encoded)
|
||||
}
|
||||
}
|
||||
if msg.Method == "tools/call" {
|
||||
if tool, ok := ctx.Value(toolContextKey).(*Tool); ok && tool != nil {
|
||||
|
|
|
|||
|
|
@ -1593,3 +1593,57 @@ func TestValidateParamHeaders_NestedArguments(t *testing.T) {
|
|||
t.Error("validateParamHeaders() = nil, want error for mismatched nested value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMcpNameHeaderEncodingRoundTrip verifies that a name which is not
|
||||
// header-safe is Base64-wrapped by the client per the 2026-07-28 transport
|
||||
// spec, and that the server's validation accepts what the client produced.
|
||||
func TestMcpNameHeaderEncodingRoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *jsonrpc.Request
|
||||
wantHeader string
|
||||
}{
|
||||
{
|
||||
name: "ascii-safe tool name is sent as-is",
|
||||
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
|
||||
wantHeader: "my-tool",
|
||||
},
|
||||
{
|
||||
name: "non-ascii tool name is base64 encoded",
|
||||
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "café-tool"})},
|
||||
wantHeader: encodeBase64("café-tool"),
|
||||
},
|
||||
{
|
||||
name: "prompt name with a leading space is base64 encoded",
|
||||
msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: " leading-space"})},
|
||||
wantHeader: encodeBase64(" leading-space"),
|
||||
},
|
||||
{
|
||||
name: "resource uri with a control character is base64 encoded",
|
||||
msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///a\tb.txt"})},
|
||||
wantHeader: encodeBase64("file:///a\tb.txt"),
|
||||
},
|
||||
{
|
||||
name: "name that looks like the sentinel is base64 encoded",
|
||||
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "=?base64?abc?="})},
|
||||
wantHeader: encodeBase64("=?base64?abc?="),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
header := http.Header{}
|
||||
header.Set(protocolVersionHeader, minVersionForStandardHeaders)
|
||||
setStandardHeaders(context.Background(), header, tt.msg)
|
||||
|
||||
if got := header.Get(nameHeader); got != tt.wantHeader {
|
||||
t.Errorf("Mcp-Name = %q, want %q", got, tt.wantHeader)
|
||||
}
|
||||
|
||||
// The server must accept the headers the client just generated.
|
||||
if err := validateMcpHeaders(header, tt.msg, nil); err != nil {
|
||||
t.Errorf("validateMcpHeaders() on client-generated headers = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue