mcp: remove session header handling in stateless mode (#952)
This commit is contained in:
parent
da3535b678
commit
8eb54632ad
4 changed files with 56 additions and 33 deletions
|
|
@ -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
|
||||
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
|
||||
|
||||
Options listed below were added and will be removed in the 1.8.0 version of the SDK.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
Options listed below were added and will be removed in the 1.8.0 version of the SDK.
|
||||
|
|
|
|||
|
|
@ -128,12 +128,13 @@ func (i *sessionInfo) stopTimer() {
|
|||
type StreamableHTTPOptions struct {
|
||||
// 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
|
||||
// server->client request is rejected immediately as there's no way for the
|
||||
// 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
|
||||
// documentation for [StreamableServerTransport].
|
||||
// In Stateless mode DELETE requests will return 405 Method Not Allowed.
|
||||
Stateless bool
|
||||
|
||||
// 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.
|
||||
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) {
|
||||
// DNS rebinding protection: auto-enabled for localhost servers.
|
||||
// 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.
|
||||
// Stateless servers only support POST. Each request creates a temporary
|
||||
// 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) {
|
||||
legacySessions := allowsessionsinstateless == "1"
|
||||
|
||||
if req.Method == http.MethodDelete && legacySessions {
|
||||
h.serveStatelessLegacyDELETE(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method != http.MethodPost {
|
||||
// RFC 9110 §15.5.6: 405 responses MUST include Allow header.
|
||||
w.Header().Set("Allow", "POST")
|
||||
|
|
@ -314,11 +335,12 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
|
|||
return
|
||||
}
|
||||
|
||||
// In stateless mode, the client may provide a session ID for application-
|
||||
// level state correlation. If absent, generate one.
|
||||
sessionID := req.Header.Get(sessionIDHeader)
|
||||
if sessionID == "" {
|
||||
sessionID = server.opts.GetSessionID()
|
||||
var sessionID string
|
||||
if legacySessions {
|
||||
sessionID = req.Header.Get(sessionIDHeader)
|
||||
if sessionID == "" {
|
||||
sessionID = server.opts.GetSessionID()
|
||||
}
|
||||
}
|
||||
|
||||
transport := &StreamableServerTransport{
|
||||
|
|
@ -345,6 +367,19 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
|
|||
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
|
||||
// contains an initialize or initialized message. If not, default session state
|
||||
// is constructed so that the session doesn't reject the request.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// Return a server with default options which should assign a random session ID.
|
||||
server := NewServer(testImpl, nil)
|
||||
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
|
||||
return server
|
||||
}, &StreamableHTTPOptions{
|
||||
Stateless: true,
|
||||
})
|
||||
t.Run("stateless", func(t *testing.T) {
|
||||
testStreamableHandler(t, statelessHandler, requests)
|
||||
testClientCompatibility(t, sessionlessHandler)
|
||||
})
|
||||
|
||||
testStreamableHandler(t, statelessHandler, requests)
|
||||
testClientCompatibility(t, statelessHandler)
|
||||
}
|
||||
|
||||
func textContent(t *testing.T, res *CallToolResult) string {
|
||||
|
|
|
|||
Loading…
Reference in a new issue