Skip to content

Commit 8eb5463

Browse files
mcp: remove session header handling in stateless mode (#952)
1 parent da3535b commit 8eb5463

4 files changed

Lines changed: 56 additions & 33 deletions

File tree

docs/mcpgodebug.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the
2727
Params), restoring the previous behavior. The default behavior was changed to
2828
align with SEP-2164 and the JSON-RPC specification.
2929

30+
- `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP
31+
servers will read the `Mcp-Session-Id` request header (or generate one via
32+
`GetSessionID`), set it on response headers, and accept `DELETE` requests,
33+
restoring the previous behavior. The default behavior was changed so that
34+
stateless servers ignore session IDs entirely and reject `DELETE` with 405.
35+
3036
### 1.6.0
3137

3238
Options listed below were added and will be removed in the 1.8.0 version of the SDK.

internal/docs/mcpgodebug.src.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the
2626
Params), restoring the previous behavior. The default behavior was changed to
2727
align with SEP-2164 and the JSON-RPC specification.
2828

29+
- `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP
30+
servers will read the `Mcp-Session-Id` request header (or generate one via
31+
`GetSessionID`), set it on response headers, and accept `DELETE` requests,
32+
restoring the previous behavior. The default behavior was changed so that
33+
stateless servers ignore session IDs entirely and reject `DELETE` with 405.
34+
2935
### 1.6.0
3036

3137
Options listed below were added and will be removed in the 1.8.0 version of the SDK.

mcp/streamable.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,13 @@ func (i *sessionInfo) stopTimer() {
128128
type StreamableHTTPOptions struct {
129129
// Stateless controls whether the session is 'stateless'.
130130
//
131-
// A stateless server does not validate the Mcp-Session-Id header, and uses a
131+
// A stateless server ignores the Mcp-Session-Id header, and uses a
132132
// temporary session with default initialization parameters. Any
133133
// server->client request is rejected immediately as there's no way for the
134134
// client to respond. Server->Client notifications may reach the client if
135135
// they are made in the context of an incoming request, as described in the
136136
// documentation for [StreamableServerTransport].
137+
// In Stateless mode DELETE requests will return 405 Method Not Allowed.
137138
Stateless bool
138139

139140
// TODO(#148): support session retention (?)
@@ -247,6 +248,16 @@ var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection")
247248
// The option will be removed in the 1.8.0 version of the SDK.
248249
var enableoriginverification = mcpgodebug.Value("enableoriginverification")
249250

251+
// allowsessionsinstateless is a compatibility parameter that restores the old
252+
// behavior of reading and using Mcp-Session-Id headers in stateless mode. When
253+
// set to "1", stateless servers will read the session ID from the request
254+
// header (or generate one via GetSessionID), set it on response headers, and
255+
// accept DELETE requests. When unset (the default), stateless servers ignore
256+
// session IDs entirely and reject DELETE with 405.
257+
// See the documentation for the mcpgodebug package for instructions how to enable it.
258+
// The option will be removed in the 1.9.0 version of the SDK.
259+
var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless")
260+
250261
func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
251262
// DNS rebinding protection: auto-enabled for localhost servers.
252263
// 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
288299
// serveStateless handles requests for stateless servers.
289300
// Stateless servers only support POST. Each request creates a temporary
290301
// session that is closed when the request completes.
302+
//
303+
// When the allowsessionsinstateless compatibility flag is set, DELETE is also
304+
// accepted (as a no-op) and session IDs are read from the request header.
291305
func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.Request) {
306+
legacySessions := allowsessionsinstateless == "1"
307+
308+
if req.Method == http.MethodDelete && legacySessions {
309+
h.serveStatelessLegacyDELETE(w, req)
310+
return
311+
}
312+
292313
if req.Method != http.MethodPost {
293314
// RFC 9110 §15.5.6: 405 responses MUST include Allow header.
294315
w.Header().Set("Allow", "POST")
@@ -314,11 +335,12 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
314335
return
315336
}
316337

317-
// In stateless mode, the client may provide a session ID for application-
318-
// level state correlation. If absent, generate one.
319-
sessionID := req.Header.Get(sessionIDHeader)
320-
if sessionID == "" {
321-
sessionID = server.opts.GetSessionID()
338+
var sessionID string
339+
if legacySessions {
340+
sessionID = req.Header.Get(sessionIDHeader)
341+
if sessionID == "" {
342+
sessionID = server.opts.GetSessionID()
343+
}
322344
}
323345

324346
transport := &StreamableServerTransport{
@@ -345,6 +367,19 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
345367
transport.ServeHTTP(w, req)
346368
}
347369

370+
// serveStatelessLegacyDELETE handles DELETE requests in stateless mode when the
371+
// allowsessionsinstateless compatibility flag is set. DELETE requires a
372+
// Mcp-Session-Id header but is otherwise a no-op since stateless servers don't
373+
// persist sessions.
374+
func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter, req *http.Request) {
375+
sessionID := req.Header.Get(sessionIDHeader)
376+
if sessionID == "" {
377+
http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest)
378+
return
379+
}
380+
w.WriteHeader(http.StatusNoContent)
381+
}
382+
348383
// ephemeralConnectOpts peeks at the request body to determine whether it
349384
// contains an initialize or initialized message. If not, default session state
350385
// is constructed so that the session doesn't reject the request.

mcp/streamable_test.go

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,40 +1703,16 @@ func TestStreamableStateless(t *testing.T) {
17031703
}
17041704
}
17051705

1706-
sessionlessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server {
1707-
// Return a stateless server which never assigns a session ID.
1708-
server := NewServer(testImpl, &ServerOptions{
1709-
GetSessionID: func() string { return "" },
1710-
})
1711-
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
1712-
return server
1713-
}, &StreamableHTTPOptions{
1714-
Stateless: true,
1715-
})
1716-
1717-
// First, test the "sessionless" stateless mode, where there is no session ID.
1718-
t.Run("sessionless", func(t *testing.T) {
1719-
testStreamableHandler(t, sessionlessHandler, requests)
1720-
testClientCompatibility(t, sessionlessHandler)
1721-
})
1722-
1723-
// Next, test the default stateless mode, where session IDs are permitted.
1724-
//
1725-
// This can be used by tools to look up application state preserved across
1726-
// subsequent requests.
1727-
requests[0].wantSessionID = true // now expect a session ID for initialize
17281706
statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server {
1729-
// Return a server with default options which should assign a random session ID.
17301707
server := NewServer(testImpl, nil)
17311708
AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi)
17321709
return server
17331710
}, &StreamableHTTPOptions{
17341711
Stateless: true,
17351712
})
1736-
t.Run("stateless", func(t *testing.T) {
1737-
testStreamableHandler(t, statelessHandler, requests)
1738-
testClientCompatibility(t, sessionlessHandler)
1739-
})
1713+
1714+
testStreamableHandler(t, statelessHandler, requests)
1715+
testClientCompatibility(t, statelessHandler)
17401716
}
17411717

17421718
func textContent(t *testing.T, res *CallToolResult) string {

0 commit comments

Comments
 (0)