Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

StatefulHTTPServerTransport: a terminated session 404s every subsequent request, including a session-less InitializeRequest — the endpoint cannot recover

オープン
#286 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 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:

  1. 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.
  2. 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 new InitializeRequest without a session ID attached.
  3. 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-Id header, 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:

  1. terminate() does not clear sessionID (L558-L580), so handleInitializationRequest rejects the new initialize at the transport level (L247-L254).
  2. terminate() calls incomingContinuation.finish() (L579). The client→server stream is over; no message can reach the Server again regardless of what handleRequest returns.
  3. Server.isInitialized is set once and never reset (L175, L969), so a second initialize reaching that Server would draw Server 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 initialize admits 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 DELETE discards the pair, so the ID answers 404 afterwards and a fresh initialize gets 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 はありません

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

modelcontextprotocol/swift-sdk のほかの issue

modelcontextprotocol/swift-sdk の issue をすべて見る

似ている issue

Swift の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。