StatefulHTTPServerTransport: a terminated session 404s every subsequent request, including a session-less InitializeRequest — the endpoint cannot recover
メンテナーはふだん 7 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 35/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- swift
調査の方向性
Start with Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift, especially handleRequest, handleInitializationRequest, and terminate(), then inspect Sources/MCP/Server/Server.swift for initialization state. Compare the transport's single-session lifecycle with the session-routing patterns described for the other SDKs. Done means session-less initialization can create a usable session while terminated or unknown session IDs receive the specified response, with lifecycle and eviction behavior defined.
索引モデルが issue の本文から書いたものです。
説明
Summary
Once a session is terminated, StatefulHTTPServerTransport.handleRequest answers 404 to every request, before method dispatch and before the validation pipeline — including an InitializeRequest that carries no session ID.
A spec-conformant client sends DELETE when it no longer needs its session (spec: SHOULD). That single well-behaved act takes the MCP endpoint out of service for the lifetime of the host process: the recovery the spec requires the client to attempt — a new InitializeRequest with no session ID — draws 404 too, forever.
Affected: 0.12.1 and main (a0ae212).
Where
StatefulHTTPServerTransport.swift#L170-L177:
public func handleRequest(_ request: HTTPRequest) async -> HTTPResponse {
if terminated {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Session has been terminated"),
sessionID: sessionID
)
}
switch request.method.uppercased() {
…
The guard does not look at the request at all — not its method, not its MCP-Session-Id header.
What the specification says
Streamable HTTP → Session Management, items 3–5:
- The server MAY terminate the session at any time, after which it MUST respond to requests containing that session ID with HTTP 404 Not Found.
- When a client receives HTTP 404 in response to a request containing an
MCP-Session-Id, it MUST start a new session by sending a newInitializeRequestwithout a session ID attached.- Clients that no longer need a particular session (e.g., because the user is leaving the client application) SHOULD send an HTTP DELETE to the MCP endpoint with the
MCP-Session-Idheader, to explicitly terminate the session.
Two divergences:
- The 404 is specified for requests containing that session ID. The transport returns it for requests that carry no session ID.
- Item 4's mandated recovery is unsatisfiable. The client does exactly what it MUST do, and the server answers 404 again. Following item 5 (a SHOULD) is what puts it in that state.
Reproduction
Package.swift pinned to main (a0ae212), then:
import Foundation
import MCP
let headers = [
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
]
func initializeBody() -> Data {
Data("""
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"repro","version":"1.0"}}}
""".utf8)
}
let transport = StatefulHTTPServerTransport()
let server = Server(name: "repro", version: "1.0.0", capabilities: .init(tools: .init()))
try await server.start(transport: transport)
// 1. A client initializes.
let first = await transport.handleRequest(
HTTPRequest(method: "POST", headers: headers, body: initializeBody()))
let sessionID = first.headers["MCP-Session-Id"] ?? "<none>"
print("1. initialize -> \(first.statusCode), MCP-Session-Id: \(sessionID)")
// 2. That client ends its session (spec item 5: SHOULD send DELETE).
var deleteHeaders = headers
deleteHeaders["MCP-Session-Id"] = sessionID
let deleted = await transport.handleRequest(HTTPRequest(method: "DELETE", headers: deleteHeaders))
print("2. DELETE (session id) -> \(deleted.statusCode)")
// 3. Spec item 4's mandated recovery: a NEW InitializeRequest, no session id.
let recovery = await transport.handleRequest(
HTTPRequest(method: "POST", headers: headers, body: initializeBody()))
let body = recovery.bodyData.flatMap { String(data: $0, encoding: .utf8) } ?? "<no body>"
print("3. initialize (no id) -> \(recovery.statusCode) \(body)")
Output:
1. initialize -> 200, MCP-Session-Id: 93B6CFC0-0483-43A7-8ECC-BE1AA43316E5
2. DELETE (session id) -> 200
3. initialize (no id) -> 404 {"id":null,"jsonrpc":"2.0","error":{"message":"Invalid Request: Not Found: Session has been terminated","code":-32600}}
Step 3 is the whole bug: a brand-new client, no session ID, told the endpoint does not exist. No further request of any kind will ever be served by that transport.
This is not a misplaced if
The obvious narrow fix — scope the guard to requests that actually carry a session ID — does not restore service. Patching exactly that on a0ae212:
if terminated, request.header(HTTPHeaderName.sessionID) != nil {
and re-running the reproduction gives:
3. initialize (no id) -> 400 {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Bad Request: Session already initialized"},"id":null}
404 becomes 400; the endpoint is just as dead. Three independent pieces of state make the Server + transport pair single-use:
terminate()does not clearsessionID(L558-L580), sohandleInitializationRequestrejects the newinitializeat the transport level (L247-L254).terminate()callsincomingContinuation.finish()(L579). The client→server stream is over; no message can reach theServeragain regardless of whathandleRequestreturns.Server.isInitializedis set once and never reset (L175, L969), so a secondinitializereaching thatServerwould drawServer is already initialized(L929).
That is all defensible: the transport is one session, and a terminated session should stay terminated. The defect is that nothing sits above it. handleRequest is simultaneously the session and the HTTP endpoint, so a request belonging to no session — a session-less initialize — has nowhere to go except into a dead one. The same conflation is why a second concurrent client's initialize draws 400 Bad Request: Session already initialized while the first is connected.
How the other MCP SDKs solve it
They all put a session map above the per-session transport, and the endpoint handler is that map — not a transport.
Python — StreamableHTTPSessionManager:
if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances:
transport = self._server_instances[request_mcp_session_id]
...
await transport.handle_request(scope, receive, send)
if transport.is_terminated:
# The client ended the session (DELETE): forget it now rather
# than when its server task winds down.
await self._discard_session(request_mcp_session_id, transport)
return
if request_mcp_session_id is None:
# New session case. ...
async with self._session_creation_lock:
http_transport = self._admit_session(requestor)
...
else:
# Unknown or expired session ID - return 404 per MCP spec
await _error_response("Session not found", 404)(scope, receive, send)
A session-less initialize never reaches a terminated transport, because it is routed to a newly admitted one.
Go — StreamableHTTPHandler is an http.Handler holding sessions map[string]*sessionInfo // keyed by session ID, with idle-session timeout.
C# — StreamableHttpHandler resolves each request through a sessionManager keyed by the Mcp-Session-Id header (sessionManager.TryGetValue(sessionId, out var session)), and removes the entry on DELETE.
TypeScript leaves the map to the embedder and documents that explicitly — which is a defensible choice, but it is a documented one, and this SDK currently neither ships the layer nor says that the embedder must.
The Swift SDK ships no such type (git grep -l SessionManager Sources/ is empty), and StatefulHTTPServerTransport's own usage documentation shows one transport wired directly into the framework handler — the exact arrangement that this bug kills.
Recommendation
1. Ship the missing layer (the real fix). An MCP-Session-Id-keyed router of Server + StatefulHTTPServerTransport pairs that is itself the endpoint handler, matching the Python/Go/C# shape:
- a session-less
initializeadmits a new pair and is served by it; - a request carrying a known session ID is forwarded to that pair;
- a request carrying an unknown, evicted or terminated session ID gets the spec's 404 — and only that case gets it;
- a 2xx
DELETEdiscards the pair, so the ID answers 404 afterwards and a freshinitializegets a fresh pair; - idle-session eviction and a cap on concurrent sessions, since sessions are now created on demand (Python and Go both carry these; note also that the replay event store grows for the life of a session, so an eviction policy is what bounds it);
- one place to configure validation, so the Origin/authentication policy cannot drift between the router's own responses and each pair's pipeline.
Because pairs become disposable, this resolves the 404-blanket, the second-client 400 Session already initialized, and the unbounded per-session event store in one move — none of which can be fixed inside a type that is definitionally one session.
2. Independently, make the terminated response accurate. Even with (1), the transport should answer per spec when driven directly: 404 only for a request carrying the terminated session ID. For a session-less request the honest answer from a single-session transport is not "Not Found" — it cannot serve the request because it is spent, which is 410 Gone or 503, with a message that says so. And whichever way this lands, StatefulHTTPServerTransport's documentation should state plainly that a terminated transport is not reusable and that the Server bound to it cannot be re-initialized, so embedders wiring one transport per endpoint know what they are signing up for.
Happy to prototype either piece if the shape above looks right.
- 主要言語
- Swift
- スター
- 1.5k
- フォーク
- 252
- PR マージ指標
- 30日以内にマージされた PR はありません
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
modelcontextprotocol/swift-sdk のほかの issue
-
NetworkTransport leaks the underlying socket (CLOSE_WAIT) when reconnection is disabled and the receive loop terminates対応中かも @skirrellyjones が 37 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
modelcontextprotocol/swift-sdk#282 ·
メンテナーはふだん 7 日以内に返信
-
Value decoding changes data-URL-looking JSON strings into Value.data対応中かも @bitbemol が 48 日前に担当しました。 オープンbug
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
modelcontextprotocol/swift-sdk#277 ·
メンテナーはふだん 7 日以内に返信
-
enhancement
難易度 1/5 1時間未満 初心者へのやさしさ 78/100
modelcontextprotocol/swift-sdk#274 ·
メンテナーはふだん 7 日以内に返信
-
`HTTPClientTransport` fails to build on Windows: `#if !os(Linux)` guards no longer match the `EventSource` platform condition再び着手できるかも このイシューのプルリクエストはマージされずにクローズされました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
modelcontextprotocol/swift-sdk#261 ·
メンテナーはふだん 7 日以内に返信
-
Value: add numberValue (Double?) that coerces .int and .double対応中かも @gsdali が 159 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
modelcontextprotocol/swift-sdk#225 ·
メンテナーはふだん 7 日以内に返信
modelcontextprotocol/swift-sdk の issue をすべて見る
似ている issue
-
area:ui enhancement good first issue
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
s3onghyun/bulchimbeon#22 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
メンテナーはふだん 1 日以内に返信
-
[BUG] File transcription skips audio chunks shorter than one second対応中かも @sabbah13 が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
altic-dev/FluidVoice#1161 ·
メンテナーはふだん 1 日以内に返信
-
swift.md: `Date(myString, strategy: .iso8601)` example omits `try` — the initializer is throwingオープン
難易度 1/5 1時間未満 初心者へのやさしさ 88/100