go-sdk/mcp/streamable_headers_test.go
Guglielmo Colombo e01639a318
feat: HTTP Header Standardization for method and name (#907)
## Description

Implements SEP-2243
(https://modelcontextprotocol.io/seps/2243-http-standardization) (HTTP
Header Standardization) for Mcp-Method and Mcp-Name standard headers
(x-mcp-param custom headers will be covvered in separate PR).

Fixes #905
2026-04-29 17:33:39 +02:00

415 lines
14 KiB
Go

// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
package mcp
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
func TestExtractName(t *testing.T) {
tests := []struct {
name string
method string
params json.RawMessage
wantName string
wantOK bool
}{
{
name: "tools/call",
method: "tools/call",
params: mustMarshal(&CallToolParams{Name: "my-tool"}),
wantName: "my-tool",
wantOK: true,
},
{
name: "prompts/get",
method: "prompts/get",
params: mustMarshal(&GetPromptParams{Name: "code_review"}),
wantName: "code_review",
wantOK: true,
},
{
name: "resources/read",
method: "resources/read",
params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"}),
wantName: "file:///info.txt",
wantOK: true,
},
{
name: "tools/call with empty name",
method: "tools/call",
params: mustMarshal(&CallToolParams{Name: ""}),
wantName: "",
wantOK: true,
},
{
name: "tool name with hyphen",
method: "tools/call",
params: mustMarshal(&CallToolParams{Name: "my-tool-v2"}),
wantName: "my-tool-v2",
wantOK: true,
},
{
name: "tool name with underscore",
method: "tools/call",
params: mustMarshal(&CallToolParams{Name: "my_tool_name"}),
wantName: "my_tool_name",
wantOK: true,
},
{
name: "resource URI with special chars",
method: "resources/read",
params: mustMarshal(&ReadResourceParams{URI: "file:///path/to/file%20name.txt"}),
wantName: "file:///path/to/file%20name.txt",
wantOK: true,
},
{
name: "resource URI with query string",
method: "resources/read",
params: mustMarshal(&ReadResourceParams{URI: "https://example.com/resource?id=123"}),
wantName: "https://example.com/resource?id=123",
wantOK: true,
},
{
name: "unrelated method",
method: "initialize",
params: mustMarshal(&InitializeParams{ProtocolVersion: "2025-06-18"}),
wantName: "",
wantOK: false,
},
{
name: "notification method",
method: "notifications/initialized",
params: nil,
wantName: "",
wantOK: false,
},
{
name: "invalid JSON params",
method: "tools/call",
params: []byte("not json"),
wantName: "",
wantOK: false,
},
{
name: "nil params",
method: "tools/call",
params: nil,
wantName: "",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotOK := extractName(tt.method, tt.params)
if gotName != tt.wantName || gotOK != tt.wantOK {
t.Errorf("extractName(%q, ...) = (%q, %v), want (%q, %v)",
tt.method, gotName, gotOK, tt.wantName, tt.wantOK)
}
})
}
}
func TestSetStandardHeaders(t *testing.T) {
tests := []struct {
name string
protocolVersion string
msg jsonrpc.Message
wantMethodHeader string
wantNameHeader string
}{
{
name: "tools/call with future version",
protocolVersion: minVersionForStandardHeaders,
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantMethodHeader: "tools/call",
wantNameHeader: "my-tool",
},
{
name: "prompts/get with future version",
protocolVersion: minVersionForStandardHeaders,
msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "code_review"})},
wantMethodHeader: "prompts/get",
wantNameHeader: "code_review",
},
{
name: "resources/read with future version",
protocolVersion: minVersionForStandardHeaders,
msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})},
wantMethodHeader: "resources/read",
wantNameHeader: "file:///info.txt",
},
{
name: "initialize sets method only",
protocolVersion: minVersionForStandardHeaders,
msg: &jsonrpc.Request{Method: "initialize", Params: mustMarshal(&InitializeParams{ProtocolVersion: minVersionForStandardHeaders})},
wantMethodHeader: "initialize",
wantNameHeader: "",
},
{
name: "notification sets method only",
protocolVersion: minVersionForStandardHeaders,
msg: &jsonrpc.Request{Method: "notifications/initialized"},
wantMethodHeader: "notifications/initialized",
wantNameHeader: "",
},
{
name: "old version skips all headers",
protocolVersion: protocolVersion20251125,
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantMethodHeader: "",
wantNameHeader: "",
},
{
name: "empty version skips all headers",
protocolVersion: "",
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantMethodHeader: "",
wantNameHeader: "",
},
{
name: "nil message is a no-op",
protocolVersion: minVersionForStandardHeaders,
msg: nil,
wantMethodHeader: "",
wantNameHeader: "",
},
{
name: "response message is ignored",
protocolVersion: minVersionForStandardHeaders,
msg: &jsonrpc.Response{},
wantMethodHeader: "",
wantNameHeader: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
header := http.Header{}
if tt.protocolVersion != "" {
header.Set(protocolVersionHeader, tt.protocolVersion)
}
setStandardHeaders(header, tt.msg)
if got := header.Get(methodHeader); got != tt.wantMethodHeader {
t.Errorf("MethodHeader = %q, want %q", got, tt.wantMethodHeader)
}
if got := header.Get(nameHeader); got != tt.wantNameHeader {
t.Errorf("NameHeader = %q, want %q", got, tt.wantNameHeader)
}
})
}
}
func TestValidateMcpHeaders(t *testing.T) {
tests := []struct {
name string
version string
methodHeader string
nameHeader string
msg jsonrpc.Message
wantErr bool
wantErrContain string
}{
{
name: "old version skips validation",
version: protocolVersion20251125,
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantErr: false,
},
{
name: "empty version skips validation",
version: "",
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantErr: false,
},
{
name: "missing Mcp-Method header",
version: minVersionForStandardHeaders,
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantErr: true,
wantErrContain: "missing required Mcp-Method header",
},
{
name: "missing Mcp-Name for tools/call",
version: minVersionForStandardHeaders,
methodHeader: "tools/call",
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantErr: true,
wantErrContain: "missing required Mcp-Name header",
},
{
name: "missing Mcp-Name for resources/read",
version: minVersionForStandardHeaders,
methodHeader: "resources/read",
msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})},
wantErr: true,
wantErrContain: "missing required Mcp-Name header",
},
{
name: "missing Mcp-Name for prompts/get",
version: minVersionForStandardHeaders,
methodHeader: "prompts/get",
msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "review"})},
wantErr: true,
wantErrContain: "missing required Mcp-Name header",
},
{
name: "method mismatch",
version: minVersionForStandardHeaders,
methodHeader: "tools/call",
msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "review"})},
wantErr: true,
wantErrContain: "Mcp-Method header value 'tools/call' does not match body value 'prompts/get'",
},
{
name: "tool name mismatch",
version: minVersionForStandardHeaders,
methodHeader: "tools/call",
nameHeader: "wrong-tool",
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "right-tool"})},
wantErr: true,
wantErrContain: "Mcp-Name header value 'wrong-tool' does not match body value 'right-tool'",
},
{
name: "resource URI mismatch",
version: minVersionForStandardHeaders,
methodHeader: "resources/read",
nameHeader: "file:///wrong.txt",
msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///right.txt"})},
wantErr: true,
wantErrContain: "Mcp-Name header value 'file:///wrong.txt' does not match body value 'file:///right.txt'",
},
{
name: "prompt name mismatch",
version: minVersionForStandardHeaders,
methodHeader: "prompts/get",
nameHeader: "wrong-prompt",
msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "right-prompt"})},
wantErr: true,
wantErrContain: "Mcp-Name header value 'wrong-prompt' does not match body value 'right-prompt'",
},
{
name: "method value is case-sensitive",
version: minVersionForStandardHeaders,
methodHeader: "TOOLS/CALL",
nameHeader: "my-tool",
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantErr: true,
wantErrContain: "Mcp-Method header value 'TOOLS/CALL' does not match body value 'tools/call'",
},
{
name: "valid tools/call",
version: minVersionForStandardHeaders,
methodHeader: "tools/call",
nameHeader: "my-tool",
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})},
wantErr: false,
},
{
name: "valid resources/read",
version: minVersionForStandardHeaders,
methodHeader: "resources/read",
nameHeader: "file:///info.txt",
msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})},
wantErr: false,
},
{
name: "valid prompts/get",
version: minVersionForStandardHeaders,
methodHeader: "prompts/get",
nameHeader: "code_review",
msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "code_review"})},
wantErr: false,
},
{
name: "valid initialize (no name needed)",
version: minVersionForStandardHeaders,
methodHeader: "initialize",
msg: &jsonrpc.Request{Method: "initialize", Params: mustMarshal(&InitializeParams{ProtocolVersion: minVersionForStandardHeaders})},
wantErr: false,
},
{
name: "valid notification (no name needed)",
version: minVersionForStandardHeaders,
methodHeader: "notifications/initialized",
msg: &jsonrpc.Request{Method: "notifications/initialized"},
wantErr: false,
},
{
name: "tool name with hyphen",
version: minVersionForStandardHeaders,
methodHeader: "tools/call",
nameHeader: "my-tool-name",
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool-name"})},
wantErr: false,
},
{
name: "tool name with underscore",
version: minVersionForStandardHeaders,
methodHeader: "tools/call",
nameHeader: "my_tool_name",
msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my_tool_name"})},
wantErr: false,
},
{
name: "resource URI with special chars",
version: minVersionForStandardHeaders,
methodHeader: "resources/read",
nameHeader: "file:///path/to/file%20name.txt",
msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///path/to/file%20name.txt"})},
wantErr: false,
},
{
name: "resource URI with query string",
version: minVersionForStandardHeaders,
methodHeader: "resources/read",
nameHeader: "https://example.com/resource?id=123",
msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "https://example.com/resource?id=123"})},
wantErr: false,
},
{
name: "response message is ignored",
version: minVersionForStandardHeaders,
msg: &jsonrpc.Response{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
header := http.Header{}
if tt.version != "" {
header.Set(protocolVersionHeader, tt.version)
}
if tt.methodHeader != "" {
header.Set(methodHeader, tt.methodHeader)
}
if tt.nameHeader != "" {
header.Set(nameHeader, tt.nameHeader)
}
err := validateMcpHeaders(header, tt.msg)
if tt.wantErr {
if err == nil {
t.Fatalf("validateMcpHeaders() = nil, want error containing %q", tt.wantErrContain)
}
if !strings.Contains(err.Error(), tt.wantErrContain) {
t.Errorf("validateMcpHeaders() error = %q, want substring %q", err.Error(), tt.wantErrContain)
}
} else if err != nil {
t.Errorf("validateMcpHeaders() = %v, want nil", err)
}
})
}
}