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:
akashjainn 2026-09-07 04:16:16 -04:00 • committed by GitHub
commit f99cdb649b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 60 additions and 1 deletions

View file

@ -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 {

View file

@ -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)
}
})
}
}