mcp: HTTP Header Standardization for x-mcp-header (#915)

## Description

Implements
[SEP-2243](https://modelcontextprotocol.io/seps/2243-http-standardization)
(HTTP Header Standardization) for x-mcp-param custom header.

Fixes https://github.com/modelcontextprotocol/go-sdk/issues/905

---------

Co-authored-by: Maciej Kisiel <mkisiel@google.com>
This commit is contained in:
Guglielmo Colombo 2026-05-08 12:39:18 +02:00 • committed by GitHub
commit 909372d8d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1915,9 +1915,9 @@ func TestStreamableGET(t *testing.T) {
}
}
// TestStreamableMcpHeaderValidation tests the server-side Mcp-Method and
// Mcp-Name header validation through the full HTTP handler, as specified
// in SEP-2243.
// TestStreamableMcpHeaderValidation tests the server-side Mcp-Method,
// Mcp-Name, and Mcp-Param header validation through the full HTTP handler,
// as specified in SEP-2243.
func TestStreamableMcpHeaderValidation(t *testing.T) {
// Temporarily register the future version so the handler accepts it.
orig := supportedProtocolVersions
@ -1930,6 +1930,25 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
return &CallToolResult{}, nil
})
server.AddTool(
&Tool{
Name: "execute_sql",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"region": map[string]any{
"type": "string",
"x-mcp-header": "Region",
},
"query": map[string]any{
"type": "string",
},
},
},
},
func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
return &CallToolResult{}, nil
})
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
defer handler.closeAll()
@ -2019,6 +2038,50 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
wantStatusCode: http.StatusOK,
wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)},
},
{
method: "POST",
headers: http.Header{
protocolVersionHeader: {minVersionForStandardHeaders},
methodHeader: {"tools/call"},
nameHeader: {"execute_sql"},
paramHeaderPrefix + "Region": {"us-west1"},
},
messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{
Name: "execute_sql",
Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"},
})},
wantStatusCode: http.StatusOK,
wantMessages: []jsonrpc.Message{resp(7, &CallToolResult{Content: []Content{}}, nil)},
},
{
method: "POST",
headers: http.Header{
protocolVersionHeader: {minVersionForStandardHeaders},
methodHeader: {"tools/call"},
nameHeader: {"execute_sql"},
paramHeaderPrefix + "Region": {"eu-central1"},
},
messages: []jsonrpc.Message{req(8, "tools/call", &CallToolParams{
Name: "execute_sql",
Arguments: map[string]any{"region": "us-west1"},
})},
wantStatusCode: http.StatusBadRequest,
wantBodyContaining: "header mismatch",
},
{
method: "POST",
headers: http.Header{
protocolVersionHeader: {minVersionForStandardHeaders},
methodHeader: {"tools/call"},
nameHeader: {"execute_sql"},
},
messages: []jsonrpc.Message{req(9, "tools/call", &CallToolParams{
Name: "execute_sql",
Arguments: map[string]any{"region": "us-west1"},
})},
wantStatusCode: http.StatusBadRequest,
wantBodyContaining: "missing",
},
})
}
@ -2170,6 +2233,174 @@ func TestStreamableMcpHeaderVersionGating(t *testing.T) {
})
}
// TestStreamableParamHeadersClientSetsHeaders verifies that the client sets
// Mcp-Param-* headers on tool calls when the tool has x-mcp-header annotations.
func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) {
orig := supportedProtocolVersions
supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders)
t.Cleanup(func() { supportedProtocolVersions = orig })
server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
server.AddTool(
&Tool{
Name: "execute_sql",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"region": map[string]any{
"type": "string",
"x-mcp-header": "Region",
},
"query": map[string]any{
"type": "string",
},
},
},
},
func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil
})
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
defer handler.closeAll()
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()
var capturedHeaders http.Header
customClient := &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get(methodHeader) == "tools/call" {
capturedHeaders = req.Header.Clone()
}
return http.DefaultTransport.RoundTrip(req)
}),
}
clientTransport := &StreamableClientTransport{
Endpoint: httpServer.URL,
HTTPClient: customClient,
}
client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil)
ctx := context.Background()
session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders})
if err != nil {
t.Fatal(err)
}
defer session.Close()
// ListTools to populate the tool cache (needed for param headers).
if _, err := session.ListTools(ctx, nil); err != nil {
t.Fatal(err)
}
_, err = session.CallTool(ctx, &CallToolParams{
Name: "execute_sql",
Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"},
})
if err != nil {
t.Fatal(err)
}
if capturedHeaders == nil {
t.Fatal("no tool call headers captured")
}
if got := capturedHeaders.Get(methodHeader); got != "tools/call" {
t.Errorf("Mcp-Method = %q, want %q", got, "tools/call")
}
if got := capturedHeaders.Get(nameHeader); got != "execute_sql" {
t.Errorf("Mcp-Name = %q, want %q", got, "execute_sql")
}
if got := capturedHeaders.Get(paramHeaderPrefix + "Region"); got != "us-west1" {
t.Errorf("Mcp-Param-Region = %q, want %q", got, "us-west1")
}
}
// TestStreamableFilterValidToolsIntegration verifies that AddTool rejects
// tools with invalid x-mcp-header annotations at registration time, and
// that valid tools are returned by ListTools.
func TestStreamableFilterValidToolsIntegration(t *testing.T) {
orig := supportedProtocolVersions
supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders)
t.Cleanup(func() { supportedProtocolVersions = orig })
server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
noop := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
return &CallToolResult{}, nil
}
// Valid tool with correct x-mcp-header annotation.
server.AddTool(&Tool{
Name: "valid-tool",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"region": map[string]any{
"type": "string",
"x-mcp-header": "Region",
},
},
},
}, noop)
// Invalid tool: x-mcp-header on an array type should panic.
func() {
defer func() {
r := recover()
if r == nil {
t.Fatal("AddTool with invalid x-mcp-header annotation did not panic")
}
}()
server.AddTool(&Tool{
Name: "invalid-tool",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"items": map[string]any{
"type": "array",
"x-mcp-header": "Items",
},
},
},
}, noop)
}()
// Tool with no x-mcp-header annotations (always valid).
server.AddTool(&Tool{
Name: "plain-tool",
InputSchema: &jsonschema.Schema{Type: "object"},
}, noop)
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
defer handler.closeAll()
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()
client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil)
ctx := context.Background()
session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders})
if err != nil {
t.Fatal(err)
}
defer session.Close()
result, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
toolNames := make([]string, len(result.Tools))
for i, tool := range result.Tools {
toolNames[i] = tool.Name
}
sort.Strings(toolNames)
wantNames := []string{"plain-tool", "valid-tool"}
if !slices.Equal(toolNames, wantNames) {
t.Errorf("ListTools returned %v, want %v", toolNames, wantNames)
}
}
// TestStreamable405AllowHeader verifies RFC 9110 §15.5.6 compliance:
// 405 Method Not Allowed responses MUST include an Allow header.
func TestStreamable405AllowHeader(t *testing.T) {