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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue