Skip to content

Commit 3288753

Browse files
struckoffAleksei Strukovezynda3
authored
Detect Pong from MCP Client and skip Session ID validation (#539)
* Detect Pong from MCP Client and skip Session ID validation. https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping * Use isJSONEmpty to handle empty Result/Error without allocations --------- Co-authored-by: Aleksei Strukov <[email protected]> Co-authored-by: Ed Zynda <[email protected]>
1 parent d70b8d2 commit 3288753

File tree

2 files changed

+153
-0
lines changed

2 files changed

+153
-0
lines changed

server/streamable_http.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package server
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -13,6 +14,7 @@ import (
1314
"sync"
1415
"sync/atomic"
1516
"time"
17+
"unicode"
1618

1719
"github.com/google/uuid"
1820
"github.com/mark3labs/mcp-go/mcp"
@@ -262,6 +264,14 @@ func (s *StreamableHTTPServer) handlePost(w http.ResponseWriter, r *http.Request
262264
return
263265
}
264266

267+
// detect empty ping response, skip session ID validation
268+
isPingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil &&
269+
(isJSONEmpty(jsonMessage.Result) && isJSONEmpty(jsonMessage.Error))
270+
271+
if isPingResponse {
272+
return
273+
}
274+
265275
// Check if this is a sampling response (has result/error but no method)
266276
isSamplingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil &&
267277
(jsonMessage.Result != nil || jsonMessage.Error != nil)
@@ -937,3 +947,51 @@ func NewTestStreamableHTTPServer(server *MCPServer, opts ...StreamableHTTPOption
937947
testServer := httptest.NewServer(sseServer)
938948
return testServer
939949
}
950+
951+
// isJSONEmpty reports whether the provided JSON value is "empty":
952+
// - null
953+
// - empty object: {}
954+
// - empty array: []
955+
// It also treats nil/whitespace-only input as empty.
956+
// It does NOT treat 0, false, "" or non-empty composites as empty.
957+
func isJSONEmpty(data json.RawMessage) bool {
958+
if len(data) == 0 {
959+
return true
960+
}
961+
962+
trimmed := bytes.TrimSpace(data)
963+
if len(trimmed) == 0 {
964+
return true
965+
}
966+
967+
switch trimmed[0] {
968+
case '{':
969+
if len(trimmed) == 2 && trimmed[1] == '}' {
970+
return true
971+
}
972+
for i := 1; i < len(trimmed); i++ {
973+
if !unicode.IsSpace(rune(trimmed[i])) {
974+
return trimmed[i] == '}'
975+
}
976+
}
977+
case '[':
978+
if len(trimmed) == 2 && trimmed[1] == ']' {
979+
return true
980+
}
981+
for i := 1; i < len(trimmed); i++ {
982+
if !unicode.IsSpace(rune(trimmed[i])) {
983+
return trimmed[i] == ']'
984+
}
985+
}
986+
987+
case '"': // treat "" as not empty
988+
return false
989+
990+
case 'n': // null
991+
return len(trimmed) == 4 &&
992+
trimmed[1] == 'u' &&
993+
trimmed[2] == 'l' &&
994+
trimmed[3] == 'l'
995+
}
996+
return false
997+
}

server/streamable_http_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,101 @@ func TestStreamableHTTP_HeaderPassthrough(t *testing.T) {
894894
}
895895
}
896896

897+
func TestStreamableHTTP_PongResponseHandling(t *testing.T) {
898+
// Ping/Pong does not require session ID
899+
// https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping
900+
mcpServer := NewMCPServer("test-mcp-server", "1.0")
901+
server := NewTestStreamableHTTPServer(mcpServer)
902+
defer server.Close()
903+
904+
t.Run("Pong response with empty result should not be treated as sampling response", func(t *testing.T) {
905+
// According to MCP spec, pong responses have empty result: {"jsonrpc": "2.0", "id": "123", "result": {}}
906+
pongResponse := map[string]any{
907+
"jsonrpc": "2.0",
908+
"id": 123,
909+
"result": map[string]any{},
910+
}
911+
912+
resp, err := postJSON(server.URL, pongResponse)
913+
if err != nil {
914+
t.Fatalf("Failed to send pong response: %v", err)
915+
}
916+
defer func() { _ = resp.Body.Close() }()
917+
918+
bodyBytes, err := io.ReadAll(resp.Body)
919+
if err != nil {
920+
t.Fatalf("Failed to read response body: %v", err)
921+
}
922+
bodyStr := string(bodyBytes)
923+
924+
if strings.Contains(bodyStr, "Missing session ID for sampling response") {
925+
t.Errorf("Pong response was incorrectly detected as sampling response. Response: %s", bodyStr)
926+
}
927+
if strings.Contains(bodyStr, "Failed to handle sampling response") {
928+
t.Errorf("Pong response was incorrectly detected as sampling response. Response: %s", bodyStr)
929+
}
930+
931+
if resp.StatusCode != http.StatusOK {
932+
t.Errorf("Expected status 200 for pong response, got %d. Body: %s", resp.StatusCode, bodyStr)
933+
}
934+
})
935+
936+
t.Run("Pong response with null result should not be treated as sampling response", func(t *testing.T) {
937+
pongResponse := map[string]any{
938+
"jsonrpc": "2.0",
939+
"id": 124,
940+
}
941+
942+
resp, err := postJSON(server.URL, pongResponse)
943+
if err != nil {
944+
t.Fatalf("Failed to send pong response: %v", err)
945+
}
946+
defer func() { _ = resp.Body.Close() }()
947+
948+
bodyBytes, err := io.ReadAll(resp.Body)
949+
if err != nil {
950+
t.Fatalf("Failed to read response body: %v", err)
951+
}
952+
bodyStr := string(bodyBytes)
953+
954+
if strings.Contains(bodyStr, "Missing session ID for sampling response") {
955+
t.Errorf("Pong response with omitted result was incorrectly detected as sampling response. Response: %s", bodyStr)
956+
}
957+
958+
if resp.StatusCode != http.StatusOK {
959+
t.Errorf("Expected status 200 for pong response, got %d. Body: %s", resp.StatusCode, bodyStr)
960+
}
961+
})
962+
963+
t.Run("Response with empty error should not be treated as sampling response", func(t *testing.T) {
964+
response := map[string]any{
965+
"jsonrpc": "2.0",
966+
"id": 125,
967+
"error": map[string]any{},
968+
}
969+
970+
resp, err := postJSON(server.URL, response)
971+
if err != nil {
972+
t.Fatalf("Failed to send response: %v", err)
973+
}
974+
defer func() { _ = resp.Body.Close() }()
975+
976+
bodyBytes, err := io.ReadAll(resp.Body)
977+
if err != nil {
978+
t.Fatalf("Failed to read response body: %v", err)
979+
}
980+
bodyStr := string(bodyBytes)
981+
982+
if strings.Contains(bodyStr, "Missing session ID for sampling response") {
983+
t.Errorf("Response with empty error was incorrectly detected as sampling response. Response: %s", bodyStr)
984+
}
985+
986+
if resp.StatusCode != http.StatusOK {
987+
t.Errorf("Expected status 200 for response with empty error, got %d. Body: %s", resp.StatusCode, bodyStr)
988+
}
989+
})
990+
}
991+
897992
func TestStreamableHTTPServer_TLS(t *testing.T) {
898993
t.Run("TLS options are set correctly", func(t *testing.T) {
899994
mcpServer := NewMCPServer("test-mcp-server", "1.0.0")

0 commit comments

Comments
 (0)