## 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
415 lines
14 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|