mcp: remove session header handling in stateless mode (#952)

This commit is contained in:
Maciej Kisiel 2026-05-15 09:51:14 +02:00 • committed by GitHub
commit 8eb54632ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 56 additions and 33 deletions

View file

@ -27,6 +27,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the
Params), restoring the previous behavior. The default behavior was changed to Params), restoring the previous behavior. The default behavior was changed to
align with SEP-2164 and the JSON-RPC specification. align with SEP-2164 and the JSON-RPC specification.
- `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP
servers will read the `Mcp-Session-Id` request header (or generate one via
`GetSessionID`), set it on response headers, and accept `DELETE` requests,
restoring the previous behavior. The default behavior was changed so that
stateless servers ignore session IDs entirely and reject `DELETE` with 405.
### 1.6.0 ### 1.6.0
Options listed below were added and will be removed in the 1.8.0 version of the SDK. Options listed below were added and will be removed in the 1.8.0 version of the SDK.

View file

@ -26,6 +26,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the
Params), restoring the previous behavior. The default behavior was changed to Params), restoring the previous behavior. The default behavior was changed to
align with SEP-2164 and the JSON-RPC specification. align with SEP-2164 and the JSON-RPC specification.
- `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP
servers will read the `Mcp-Session-Id` request header (or generate one via
`GetSessionID`), set it on response headers, and accept `DELETE` requests,
restoring the previous behavior. The default behavior was changed so that
stateless servers ignore session IDs entirely and reject `DELETE` with 405.
### 1.6.0 ### 1.6.0
Options listed below were added and will be removed in the 1.8.0 version of the SDK. Options listed below were added and will be removed in the 1.8.0 version of the SDK.

View file

@ -128,12 +128,13 @@ func (i *sessionInfo) stopTimer() {
type StreamableHTTPOptions struct { type StreamableHTTPOptions struct {
// Stateless controls whether the session is 'stateless'. // Stateless controls whether the session is 'stateless'.
// //
// A stateless server does not validate the Mcp-Session-Id header, and uses a // A stateless server ignores the Mcp-Session-Id header, and uses a
// temporary session with default initialization parameters. Any // temporary session with default initialization parameters. Any
// server->client request is rejected immediately as there's no way for the // server->client request is rejected immediately as there's no way for the
// client to respond. Server->Client notifications may reach the client if // client to respond. Server->Client notifications may reach the client if
// they are made in the context of an incoming request, as described in the // they are made in the context of an incoming request, as described in the
// documentation for [StreamableServerTransport]. // documentation for [StreamableServerTransport].
// In Stateless mode DELETE requests will return 405 Method Not Allowed.
Stateless bool Stateless bool
// TODO(#148): support session retention (?) // TODO(#148): support session retention (?)
@ -247,6 +248,16 @@ var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection")
// The option will be removed in the 1.8.0 version of the SDK. // The option will be removed in the 1.8.0 version of the SDK.
var enableoriginverification = mcpgodebug.Value("enableoriginverification") var enableoriginverification = mcpgodebug.Value("enableoriginverification")
// allowsessionsinstateless is a compatibility parameter that restores the old
// behavior of reading and using Mcp-Session-Id headers in stateless mode. When
// set to "1", stateless servers will read the session ID from the request
// header (or generate one via GetSessionID), set it on response headers, and
// accept DELETE requests. When unset (the default), stateless servers ignore
// session IDs entirely and reject DELETE with 405.
// See the documentation for the mcpgodebug package for instructions how to enable it.
// The option will be removed in the 1.9.0 version of the SDK.
var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless")
func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// DNS rebinding protection: auto-enabled for localhost servers. // DNS rebinding protection: auto-enabled for localhost servers.
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
@ -288,7 +299,17 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
// serveStateless handles requests for stateless servers. // serveStateless handles requests for stateless servers.
// Stateless servers only support POST. Each request creates a temporary // Stateless servers only support POST. Each request creates a temporary
// session that is closed when the request completes. // session that is closed when the request completes.
//
// When the allowsessionsinstateless compatibility flag is set, DELETE is also
// accepted (as a no-op) and session IDs are read from the request header.
func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.Request) { func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.Request) {
legacySessions := allowsessionsinstateless == "1"
if req.Method == http.MethodDelete && legacySessions {
h.serveStatelessLegacyDELETE(w, req)
return
}
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
// RFC 9110 §15.5.6: 405 responses MUST include Allow header. // RFC 9110 §15.5.6: 405 responses MUST include Allow header.
w.Header().Set("Allow", "POST") w.Header().Set("Allow", "POST")
@ -314,11 +335,12 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
return return
} }
// In stateless mode, the client may provide a session ID for application- var sessionID string
// level state correlation. If absent, generate one. if legacySessions {
sessionID := req.Header.Get(sessionIDHeader) sessionID = req.Header.Get(sessionIDHeader)
if sessionID == "" { if sessionID == "" {
sessionID = server.opts.GetSessionID() sessionID = server.opts.GetSessionID()
}
} }
transport := &StreamableServerTransport{ transport := &StreamableServerTransport{
@ -345,6 +367,19 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
transport.ServeHTTP(w, req) transport.ServeHTTP(w, req)
} }
// serveStatelessLegacyDELETE handles DELETE requests in stateless mode when the
// allowsessionsinstateless compatibility flag is set. DELETE requires a
// Mcp-Session-Id header but is otherwise a no-op since stateless servers don't
// persist sessions.
func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter, req *http.Request) {
sessionID := req.Header.Get(sessionIDHeader)
if sessionID == "" {
http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ephemeralConnectOpts peeks at the request body to determine whether it // ephemeralConnectOpts peeks at the request body to determine whether it
// contains an initialize or initialized message. If not, default session state // contains an initialize or initialized message. If not, default session state
// is constructed so that the session doesn't reject the request. // is constructed so that the session doesn't reject the request.

View file

@ -1703,40 +1703,16 @@ func TestStreamableStateless(t *testing.T) {
} }
} }
sessionlessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server {
// Return a stateless server which never assigns a session ID.
server := NewServer(testImpl, &ServerOptions{
GetSessionID: func() string { return "" },
})
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
return server
}, &StreamableHTTPOptions{
Stateless: true,
})
// First, test the "sessionless" stateless mode, where there is no session ID.
t.Run("sessionless", func(t *testing.T) {
testStreamableHandler(t, sessionlessHandler, requests)
testClientCompatibility(t, sessionlessHandler)
})
// Next, test the default stateless mode, where session IDs are permitted.
//
// This can be used by tools to look up application state preserved across
// subsequent requests.
requests[0].wantSessionID = true // now expect a session ID for initialize
statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server {
// Return a server with default options which should assign a random session ID.
server := NewServer(testImpl, nil) server := NewServer(testImpl, nil)
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
return server return server
}, &StreamableHTTPOptions{ }, &StreamableHTTPOptions{
Stateless: true, Stateless: true,
}) })
t.Run("stateless", func(t *testing.T) {
testStreamableHandler(t, statelessHandler, requests) testStreamableHandler(t, statelessHandler, requests)
testClientCompatibility(t, sessionlessHandler) testClientCompatibility(t, statelessHandler)
})
} }
func textContent(t *testing.T, res *CallToolResult) string { func textContent(t *testing.T, res *CallToolResult) string {