From 247a5505a775e2ebba881dcef29a1b30bc61b7ab Mon Sep 17 00:00:00 2001 From: Aleksei Strukov Date: Mon, 11 Aug 2025 19:19:33 +0200 Subject: [PATCH 1/2] Detect Pong from MCP Client and skip Session ID validation. https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping --- server/streamable_http.go | 13 ++++- server/streamable_http_test.go | 95 ++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/server/streamable_http.go b/server/streamable_http.go index 24ec1c95..68a8980a 100644 --- a/server/streamable_http.go +++ b/server/streamable_http.go @@ -236,10 +236,19 @@ func (s *StreamableHTTPServer) handlePost(w http.ResponseWriter, r *http.Request return } + // detect empty ping response, skip session ID validation + isPingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil && + ((jsonMessage.Result == nil || strings.TrimSpace(string(jsonMessage.Result)) == "{}") && + (jsonMessage.Error == nil || strings.TrimSpace(string(jsonMessage.Error)) == "{}")) + + if isPingResponse { + return + } + // Check if this is a sampling response (has result/error but no method) - isSamplingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil && + isSamplingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil && (jsonMessage.Result != nil || jsonMessage.Error != nil) - + isInitializeRequest := jsonMessage.Method == mcp.MethodInitialize // Handle sampling responses separately diff --git a/server/streamable_http_test.go b/server/streamable_http_test.go index 105fd18c..136f20b4 100644 --- a/server/streamable_http_test.go +++ b/server/streamable_http_test.go @@ -894,6 +894,101 @@ func TestStreamableHTTP_HeaderPassthrough(t *testing.T) { } } +func TestStreamableHTTP_PongResponseHandling(t *testing.T) { + // Ping/Pong does not require session ID + // https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping + mcpServer := NewMCPServer("test-mcp-server", "1.0") + server := NewTestStreamableHTTPServer(mcpServer) + defer server.Close() + + t.Run("Pong response with empty result should not be treated as sampling response", func(t *testing.T) { + // According to MCP spec, pong responses have empty result: {"jsonrpc": "2.0", "id": "123", "result": {}} + pongResponse := map[string]any{ + "jsonrpc": "2.0", + "id": 123, + "result": map[string]any{}, + } + + resp, err := postJSON(server.URL, pongResponse) + if err != nil { + t.Fatalf("Failed to send pong response: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + bodyStr := string(bodyBytes) + + if strings.Contains(bodyStr, "Missing session ID for sampling response") { + t.Errorf("Pong response was incorrectly detected as sampling response. Response: %s", bodyStr) + } + if strings.Contains(bodyStr, "Failed to handle sampling response") { + t.Errorf("Pong response was incorrectly detected as sampling response. Response: %s", bodyStr) + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200 for pong response, got %d. Body: %s", resp.StatusCode, bodyStr) + } + }) + + t.Run("Pong response with null result should not be treated as sampling response", func(t *testing.T) { + pongResponse := map[string]any{ + "jsonrpc": "2.0", + "id": 124, + } + + resp, err := postJSON(server.URL, pongResponse) + if err != nil { + t.Fatalf("Failed to send pong response: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + bodyStr := string(bodyBytes) + + if strings.Contains(bodyStr, "Missing session ID for sampling response") { + t.Errorf("Pong response with omitted result was incorrectly detected as sampling response. Response: %s", bodyStr) + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200 for pong response, got %d. Body: %s", resp.StatusCode, bodyStr) + } + }) + + t.Run("Response with empty error should not be treated as sampling response", func(t *testing.T) { + response := map[string]any{ + "jsonrpc": "2.0", + "id": 125, + "error": map[string]any{}, + } + + resp, err := postJSON(server.URL, response) + if err != nil { + t.Fatalf("Failed to send response: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + bodyStr := string(bodyBytes) + + if strings.Contains(bodyStr, "Missing session ID for sampling response") { + t.Errorf("Response with empty error was incorrectly detected as sampling response. Response: %s", bodyStr) + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200 for response with empty error, got %d. Body: %s", resp.StatusCode, bodyStr) + } + }) +} + func postJSON(url string, bodyObject any) (*http.Response, error) { jsonBody, _ := json.Marshal(bodyObject) req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonBody)) From b8977cd62c61af86d3d99acdd53a0f04073ba950 Mon Sep 17 00:00:00 2001 From: Aleksei Strukov Date: Tue, 12 Aug 2025 13:04:25 +0200 Subject: [PATCH 2/2] Use isJSONEmpty to handle empty Result/Error without allocations --- server/streamable_http.go | 53 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/server/streamable_http.go b/server/streamable_http.go index 68a8980a..bf67904f 100644 --- a/server/streamable_http.go +++ b/server/streamable_http.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "context" "encoding/json" "fmt" @@ -12,6 +13,7 @@ import ( "sync" "sync/atomic" "time" + "unicode" "github.com/google/uuid" "github.com/mark3labs/mcp-go/mcp" @@ -238,8 +240,7 @@ func (s *StreamableHTTPServer) handlePost(w http.ResponseWriter, r *http.Request // detect empty ping response, skip session ID validation isPingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil && - ((jsonMessage.Result == nil || strings.TrimSpace(string(jsonMessage.Result)) == "{}") && - (jsonMessage.Error == nil || strings.TrimSpace(string(jsonMessage.Error)) == "{}")) + (isJSONEmpty(jsonMessage.Result) && isJSONEmpty(jsonMessage.Error)) if isPingResponse { return @@ -920,3 +921,51 @@ func NewTestStreamableHTTPServer(server *MCPServer, opts ...StreamableHTTPOption testServer := httptest.NewServer(sseServer) return testServer } + +// isJSONEmpty reports whether the provided JSON value is "empty": +// - null +// - empty object: {} +// - empty array: [] +// It also treats nil/whitespace-only input as empty. +// It does NOT treat 0, false, "" or non-empty composites as empty. +func isJSONEmpty(data json.RawMessage) bool { + if len(data) == 0 { + return true + } + + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return true + } + + switch trimmed[0] { + case '{': + if len(trimmed) == 2 && trimmed[1] == '}' { + return true + } + for i := 1; i < len(trimmed); i++ { + if !unicode.IsSpace(rune(trimmed[i])) { + return trimmed[i] == '}' + } + } + case '[': + if len(trimmed) == 2 && trimmed[1] == ']' { + return true + } + for i := 1; i < len(trimmed); i++ { + if !unicode.IsSpace(rune(trimmed[i])) { + return trimmed[i] == ']' + } + } + + case '"': // treat "" as not empty + return false + + case 'n': // null + return len(trimmed) == 4 && + trimmed[1] == 'u' && + trimmed[2] == 'l' && + trimmed[3] == 'l' + } + return false +}