mcp: Implement stateless server (SEP-2275) (#965)
## Description
This PR lays the foundational server-side groundwork for the `>=
2026-06-30` sessionless and stateless feature introduced by
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)
and SEP-2567, tracked in
[design/stateless.md](614460a225/design/stateless.md).
### SEP-2575: Stateless MCP
* **Per-request protocol detection in `ServerSession.handle()`**
Unmarshal `_meta` from the raw JSON-RPC params determines whether each
request follows the new protocol.
* **Per-request typed accessors on `ServerRequest[P]`**
```go
func (r *ServerRequest[P]) ProtocolVersion() string
func (r *ServerRequest[P]) ClientInfo() *Implementation
func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities
```
* **Reject client->server `initialize`, `initialized` and `ping` for
new-protocol requests**
* **Per-request `_meta` field name constants**
Three constants for the wire-protocol field names
(`MetaKeyProtocolVersion`, `MetaKeyClientInfo`,
`MetaKeyClientCapabilities`)
* **Stop synthesizing fake `InitializeParams` for new-protocol
requests**
Fixes: #966
This commit is contained in:
parent
c044cdb734
commit
4cbdd6a514
1 changed files with 1113 additions and 62 deletions
|
|
@ -1926,39 +1926,18 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
return &CallToolResult{}, nil
|
||||
})
|
||||
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{
|
||||
Stateless: true,
|
||||
})
|
||||
defer handler.closeAll()
|
||||
|
||||
initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: minVersionForStandardHeaders})
|
||||
initResp := resp(1, &InitializeResult{
|
||||
Capabilities: &ServerCapabilities{
|
||||
Logging: &LoggingCapabilities{},
|
||||
Tools: &ToolCapabilities{ListChanged: true},
|
||||
},
|
||||
ProtocolVersion: minVersionForStandardHeaders,
|
||||
ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"},
|
||||
}, nil)
|
||||
|
||||
initialize := streamableRequest{
|
||||
method: "POST",
|
||||
messages: []jsonrpc.Message{initReq},
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantMessages: []jsonrpc.Message{initResp},
|
||||
wantSessionID: true,
|
||||
}
|
||||
initialized := streamableRequest{
|
||||
method: "POST",
|
||||
headers: http.Header{
|
||||
protocolVersionHeader: {minVersionForStandardHeaders},
|
||||
methodHeader: {notificationInitialized},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(0, notificationInitialized, &InitializedParams{})},
|
||||
wantStatusCode: http.StatusAccepted,
|
||||
testMeta := Meta{
|
||||
MetaKeyProtocolVersion: minVersionForStandardHeaders,
|
||||
MetaKeyClientInfo: map[string]any{"name": "testClient", "version": "v1.0.0"},
|
||||
MetaKeyClientCapabilities: map[string]any{},
|
||||
}
|
||||
|
||||
testStreamableHandler(t, handler, []streamableRequest{
|
||||
initialize,
|
||||
initialized,
|
||||
{
|
||||
method: "POST",
|
||||
headers: http.Header{
|
||||
|
|
@ -1966,7 +1945,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
methodHeader: {"tools/call"},
|
||||
nameHeader: {"my-tool"},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "my-tool"})},
|
||||
messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})},
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)},
|
||||
},
|
||||
|
|
@ -1977,7 +1956,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
methodHeader: {"prompts/get"},
|
||||
nameHeader: {"my-tool"},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "my-tool"})},
|
||||
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})},
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
wantBodyContaining: "Mcp-Method header value",
|
||||
},
|
||||
|
|
@ -1988,7 +1967,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
methodHeader: {"tools/call"},
|
||||
nameHeader: {"wrong-tool"},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "my-tool"})},
|
||||
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})},
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
wantBodyContaining: "Mcp-Name header value",
|
||||
},
|
||||
|
|
@ -1999,7 +1978,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
methodHeader: {"TOOLS/CALL"},
|
||||
nameHeader: {"my-tool"},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "my-tool"})},
|
||||
messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})},
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
wantBodyContaining: "Mcp-Method header value",
|
||||
},
|
||||
|
|
@ -2010,7 +1989,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
methodHeader: {"tools/call"},
|
||||
nameHeader: {"my-tool"},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Name: "my-tool"})},
|
||||
messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})},
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)},
|
||||
},
|
||||
|
|
@ -2023,6 +2002,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
paramHeaderPrefix + "Region": {"us-west1"},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{
|
||||
Meta: testMeta,
|
||||
Name: "execute_sql",
|
||||
Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"},
|
||||
})},
|
||||
|
|
@ -2038,6 +2018,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
paramHeaderPrefix + "Region": {"eu-central1"},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(8, "tools/call", &CallToolParams{
|
||||
Meta: testMeta,
|
||||
Name: "execute_sql",
|
||||
Arguments: map[string]any{"region": "us-west1"},
|
||||
})},
|
||||
|
|
@ -2052,6 +2033,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
nameHeader: {"execute_sql"},
|
||||
},
|
||||
messages: []jsonrpc.Message{req(9, "tools/call", &CallToolParams{
|
||||
Meta: testMeta,
|
||||
Name: "execute_sql",
|
||||
Arguments: map[string]any{"region": "us-west1"},
|
||||
})},
|
||||
|
|
@ -2061,6 +2043,68 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TODO: Remove this once client operations will automatically inject metadata in the requests
|
||||
func injectMetaToRequest(req *http.Request) error {
|
||||
if req.Body == nil {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Body.Close()
|
||||
|
||||
var val any
|
||||
if err := json.Unmarshal(body, &val); err == nil {
|
||||
var method string
|
||||
if m, ok := val.(map[string]any); ok {
|
||||
method, _ = m["method"].(string)
|
||||
} else if list, ok := val.([]any); ok && len(list) > 0 {
|
||||
if m, ok := list[0].(map[string]any); ok {
|
||||
method, _ = m["method"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
if method == "initialize" || method == "notifications/initialized" || strings.HasPrefix(method, "notifications/") {
|
||||
req.Header.Set(protocolVersionHeader, "2025-11-25")
|
||||
} else {
|
||||
req.Header.Set(protocolVersionHeader, minVersionForStandardHeaders)
|
||||
|
||||
var msgs []map[string]any
|
||||
if m, ok := val.(map[string]any); ok {
|
||||
msgs = []map[string]any{m}
|
||||
} else if list, ok := val.([]any); ok {
|
||||
for _, item := range list {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range msgs {
|
||||
params, _ := m["params"].(map[string]any)
|
||||
if params == nil {
|
||||
params = make(map[string]any)
|
||||
m["params"] = params
|
||||
}
|
||||
meta, _ := params["_meta"].(map[string]any)
|
||||
if meta == nil {
|
||||
meta = make(map[string]any)
|
||||
params["_meta"] = meta
|
||||
}
|
||||
meta[MetaKeyProtocolVersion] = minVersionForStandardHeaders
|
||||
meta[MetaKeyClientInfo] = map[string]any{"name": "testClient", "version": "v1.0.0"}
|
||||
meta[MetaKeyClientCapabilities] = map[string]any{}
|
||||
}
|
||||
body, _ = json.Marshal(val)
|
||||
}
|
||||
}
|
||||
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestStreamableMcpHeaderValidationErrorFormat verifies that header
|
||||
// validation errors return a JSON-RPC error with code -32001 and
|
||||
// Content-Type application/json, per SEP-2243.
|
||||
|
|
@ -2076,7 +2120,9 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) {
|
|||
return &CallToolResult{}, nil
|
||||
})
|
||||
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{
|
||||
Stateless: true,
|
||||
})
|
||||
defer handler.closeAll()
|
||||
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, handler))
|
||||
|
|
@ -2088,6 +2134,9 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) {
|
|||
|
||||
customClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := injectMetaToRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var originalMethodHeader string
|
||||
if req.Header.Get(methodHeader) == "tools/call" {
|
||||
originalMethodHeader = req.Header.Get(methodHeader)
|
||||
|
|
@ -2237,7 +2286,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) {
|
|||
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil
|
||||
})
|
||||
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{
|
||||
Stateless: true,
|
||||
})
|
||||
defer handler.closeAll()
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, handler))
|
||||
defer httpServer.Close()
|
||||
|
|
@ -2245,6 +2296,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) {
|
|||
var capturedHeaders http.Header
|
||||
customClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := injectMetaToRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Header.Get(methodHeader) == "tools/call" {
|
||||
capturedHeaders = req.Header.Clone()
|
||||
}
|
||||
|
|
@ -2347,14 +2401,28 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) {
|
|||
InputSchema: &jsonschema.Schema{Type: "object"},
|
||||
}, noop)
|
||||
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{
|
||||
Stateless: true,
|
||||
})
|
||||
defer handler.closeAll()
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, handler))
|
||||
defer httpServer.Close()
|
||||
|
||||
customClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := injectMetaToRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}),
|
||||
}
|
||||
|
||||
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})
|
||||
session, err := client.Connect(ctx, &StreamableClientTransport{
|
||||
Endpoint: httpServer.URL,
|
||||
HTTPClient: customClient,
|
||||
}, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -3207,3 +3275,314 @@ func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) {
|
|||
t.Fatal("timed out waiting for first SSE bytes; the standalone SSE stream must emit a DATA frame immediately so HTTP/2 reverse proxies don't buffer the HEADERS frame")
|
||||
}
|
||||
}
|
||||
|
||||
// newProtocolBody builds a raw JSON body for a tools/call request that
|
||||
// carries the >= 2026-06-30 per-request _meta fields.
|
||||
func newProtocolBody(t *testing.T, toolName string, args any) []byte {
|
||||
t.Helper()
|
||||
rawArgs, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{
|
||||
"_meta": map[string]any{
|
||||
MetaKeyProtocolVersion: protocolVersion20260630,
|
||||
MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"},
|
||||
MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}},
|
||||
},
|
||||
"name": toolName,
|
||||
"arguments": json.RawMessage(rawArgs),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestEphemeralConnectOpts(t *testing.T) {
|
||||
mkReq := func(body []byte) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
return r
|
||||
}
|
||||
|
||||
h := &StreamableHTTPHandler{opts: StreamableHTTPOptions{}}
|
||||
|
||||
oldProtocolBody, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{"name": "x", "arguments": map[string]any{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initializeBody, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": methodInitialize,
|
||||
"params": map[string]any{"protocolVersion": protocolVersion20250618},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
wantUsesNew bool
|
||||
wantInitializeParams bool
|
||||
wantInitializedParams bool
|
||||
}{
|
||||
{
|
||||
name: "new-protocol request: no synthetic state",
|
||||
body: newProtocolBody(t, "x", struct{}{}),
|
||||
wantUsesNew: true,
|
||||
wantInitializeParams: false,
|
||||
wantInitializedParams: false,
|
||||
},
|
||||
{
|
||||
name: "old-protocol request: synthetic state populated",
|
||||
body: oldProtocolBody,
|
||||
wantUsesNew: false,
|
||||
wantInitializeParams: true,
|
||||
wantInitializedParams: true,
|
||||
},
|
||||
{
|
||||
name: "initialize request: no synthetic InitializeParams",
|
||||
body: initializeBody,
|
||||
wantUsesNew: false,
|
||||
wantInitializeParams: false,
|
||||
wantInitializedParams: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := mkReq(tt.body)
|
||||
var pver string
|
||||
if tt.wantUsesNew {
|
||||
pver = protocolVersion20260630
|
||||
} else {
|
||||
pver = protocolVersion20250326
|
||||
}
|
||||
req.Header.Set(protocolVersionHeader, pver)
|
||||
req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, pver))
|
||||
opts, usesNew, err := h.ephemeralConnectOpts(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usesNew != tt.wantUsesNew {
|
||||
t.Errorf("usesNewProtocol = %v, want %v", usesNew, tt.wantUsesNew)
|
||||
}
|
||||
if got := opts.State.InitializeParams != nil; got != tt.wantInitializeParams {
|
||||
t.Errorf("InitializeParams non-nil = %v, want %v (value = %+v)",
|
||||
got, tt.wantInitializeParams, opts.State.InitializeParams)
|
||||
}
|
||||
if got := opts.State.InitializedParams != nil; got != tt.wantInitializedParams {
|
||||
t.Errorf("InitializedParams non-nil = %v, want %v (value = %+v)",
|
||||
got, tt.wantInitializedParams, opts.State.InitializedParams)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// statelessHandlerCapture builds a stateless server with a single tool whose
|
||||
// handler captures everything we want to assert about the per-request view of
|
||||
// the session and the new-protocol accessors.
|
||||
type statelessHandlerCapture struct {
|
||||
mu sync.Mutex
|
||||
sessionInitParams *InitializeParams
|
||||
reqProtocolVersion string
|
||||
reqClientInfo *Implementation
|
||||
reqClientCapabilities *ClientCapabilities
|
||||
}
|
||||
|
||||
func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) {
|
||||
// SEP-2575: the MCP-Protocol-Version header is mandatory for new-protocol
|
||||
// requests and must be a supported version. The 2026-06-30 version is
|
||||
// not yet in the global list, so register it for the duration of the test.
|
||||
orig := supportedProtocolVersions
|
||||
supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630)
|
||||
t.Cleanup(func() { supportedProtocolVersions = orig })
|
||||
|
||||
capture := &statelessHandlerCapture{}
|
||||
mcpServer := NewServer(testImpl, nil)
|
||||
AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"},
|
||||
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
|
||||
capture.mu.Lock()
|
||||
defer capture.mu.Unlock()
|
||||
capture.sessionInitParams = req.Session.InitializeParams()
|
||||
capture.reqProtocolVersion = req.ProtocolVersion()
|
||||
capture.reqClientInfo = req.ClientInfo()
|
||||
capture.reqClientCapabilities = req.ClientCapabilities()
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
|
||||
})
|
||||
|
||||
handler := NewStreamableHTTPHandler(
|
||||
func(*http.Request) *Server { return mcpServer },
|
||||
&StreamableHTTPOptions{Stateless: true},
|
||||
)
|
||||
httpServer := httptest.NewServer(handler)
|
||||
defer httpServer.Close()
|
||||
|
||||
body := newProtocolBody(t, "capture", struct{}{})
|
||||
httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json, text/event-stream")
|
||||
httpReq.Header.Set(protocolVersionHeader, protocolVersion20260630)
|
||||
// >= 2026-06-30 also requires the Mcp-Method and Mcp-Name standard
|
||||
// headers (see streamable_headers.go).
|
||||
httpReq.Header.Set(methodHeader, "tools/call")
|
||||
httpReq.Header.Set(nameHeader, "capture")
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
capture.mu.Lock()
|
||||
defer capture.mu.Unlock()
|
||||
if capture.sessionInitParams == nil {
|
||||
t.Errorf("Session.InitializeParams() is nil, want populated initializeParams for new-protocol session")
|
||||
} else {
|
||||
if got, want := capture.sessionInitParams.ProtocolVersion, protocolVersion20260630; got != want {
|
||||
t.Errorf("Session.InitializeParams().ProtocolVersion = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := capture.sessionInitParams.ClientInfo.Name, "new-proto-client"; got != want {
|
||||
t.Errorf("Session.InitializeParams().ClientInfo.Name = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
if got, want := capture.reqProtocolVersion, protocolVersion20260630; got != want {
|
||||
t.Errorf("req.ProtocolVersion() = %q, want %q", got, want)
|
||||
}
|
||||
if capture.reqClientInfo == nil || capture.reqClientInfo.Name != "new-proto-client" {
|
||||
t.Errorf("req.ClientInfo() = %+v, want Name=new-proto-client", capture.reqClientInfo)
|
||||
}
|
||||
if capture.reqClientCapabilities == nil || capture.reqClientCapabilities.Sampling == nil {
|
||||
t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamableStateful_RejectsNewProtocol verifies that a stateful HTTP
|
||||
// server rejects requests carrying _meta.protocolVersion (i.e. >= 2026-06-30
|
||||
// requests) with HTTP 400. The new protocol is
|
||||
// supported on HTTP only when StreamableHTTPOptions.Stateless=true.
|
||||
func TestStreamableStateful_RejectsNewProtocol(t *testing.T) {
|
||||
// Make 2026-06-30 a "known" version so that the request reaches servePOST
|
||||
// (otherwise the early header validation at ServeHTTP rejects it).
|
||||
orig := supportedProtocolVersions
|
||||
supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630)
|
||||
t.Cleanup(func() { supportedProtocolVersions = orig })
|
||||
|
||||
server := NewServer(testImpl, nil)
|
||||
AddTool(server, &Tool{Name: "noop"},
|
||||
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
|
||||
})
|
||||
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)
|
||||
httpServer := httptest.NewServer(handler)
|
||||
defer httpServer.Close()
|
||||
|
||||
// Initialize a legacy session first.
|
||||
initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
|
||||
initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initReq.Header.Set("Content-Type", "application/json")
|
||||
initReq.Header.Set("Accept", "application/json, text/event-stream")
|
||||
initResp, err := http.DefaultClient.Do(initReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, initResp.Body)
|
||||
initResp.Body.Close()
|
||||
sessionID := initResp.Header.Get(sessionIDHeader)
|
||||
if sessionID == "" {
|
||||
t.Fatalf("initialize response missing %s header", sessionIDHeader)
|
||||
}
|
||||
|
||||
// Drive the existing session with a new-protocol request whose header and
|
||||
// body agree. The cross-check passes; the stateful-rejection check fires.
|
||||
body := newProtocolBody(t, "noop", struct{}{})
|
||||
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set(sessionIDHeader, sessionID)
|
||||
req.Header.Set(protocolVersionHeader, protocolVersion20260630)
|
||||
req.Header.Set(methodHeader, "tools/call")
|
||||
req.Header.Set(nameHeader, "noop")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody)
|
||||
}
|
||||
if !strings.Contains(string(respBody), "stateless") {
|
||||
t.Errorf("body = %q, want a message mentioning 'stateless'", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamableStateless_AcceptsNewProtocol is the positive control:
|
||||
// confirms that a stateless server still accepts new-protocol requests
|
||||
// (the rejection in TestStreamableStateful_RejectsNewProtocol must not
|
||||
// fire on Stateless: true).
|
||||
func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) {
|
||||
orig := supportedProtocolVersions
|
||||
supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630)
|
||||
t.Cleanup(func() { supportedProtocolVersions = orig })
|
||||
|
||||
server := NewServer(testImpl, nil)
|
||||
AddTool(server, &Tool{Name: "noop"},
|
||||
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
|
||||
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
|
||||
})
|
||||
handler := NewStreamableHTTPHandler(
|
||||
func(*http.Request) *Server { return server },
|
||||
&StreamableHTTPOptions{Stateless: true},
|
||||
)
|
||||
httpServer := httptest.NewServer(handler)
|
||||
defer httpServer.Close()
|
||||
|
||||
body := newProtocolBody(t, "noop", struct{}{})
|
||||
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set(protocolVersionHeader, protocolVersion20260630)
|
||||
req.Header.Set(methodHeader, "tools/call")
|
||||
req.Header.Set(nameHeader, "noop")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue