From e28d786de41e742cf0343f592e51b5f5fb6bc915 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 29 Jul 2026 21:09:20 -0600 Subject: [PATCH 1/2] fix(transport): return 404 for stale sessions Return the HTTP status required by Streamable HTTP when a client sends an unknown MCP session ID. Keep the JSON-RPC -32001 body so clients can log the protocol error while using HTTP 404 to start a new session. --- src/server.jl | 18 ++++++++++++++---- test/runtests.jl | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/server.jl b/src/server.jl index 329975a..36d513e 100644 --- a/src/server.jl +++ b/src/server.jl @@ -981,7 +981,15 @@ function jsonrpc_success(server::MCPServer, session::Union{MCPSession,Nothing}, return HTTP.Response(200, response_headers(server; session=session), JSON.json(body)) end -function jsonrpc_error(server::MCPServer, session::Union{MCPSession,Nothing}, id, code::Int, message::AbstractString; data=nothing) +function jsonrpc_error( + server::MCPServer, + session::Union{MCPSession,Nothing}, + id, + code::Int, + message::AbstractString; + data=nothing, + status::Int=200, +) error = Dict("code" => code, "message" => String(message)) data === nothing || (error["data"] = data) body = Dict( @@ -989,7 +997,7 @@ function jsonrpc_error(server::MCPServer, session::Union{MCPSession,Nothing}, id "id" => id, "error" => error, ) - return HTTP.Response(200, response_headers(server; session=session), JSON.json(body)) + return HTTP.Response(status, response_headers(server; session=session), JSON.json(body)) end function params_dict(params) @@ -1401,7 +1409,8 @@ function handle_jsonrpc_request(server::MCPServer, req::HTTP.Request) catch err if err isa MCPError code, message = classify_error(err) - response = jsonrpc_error(server, nothing, nothing, code, message) + status = err.code == :invalid_session ? 404 : 200 + response = jsonrpc_error(server, nothing, nothing, code, message; status) return response else rethrow(err) @@ -1440,7 +1449,8 @@ function handle_jsonrpc_request(server::MCPServer, req::HTTP.Request) catch err if err isa MCPError code, message = classify_error(err) - response = jsonrpc_error(server, nothing, id, code, message) + status = err.code == :invalid_session ? 404 : 200 + response = jsonrpc_error(server, nothing, id, code, message; status) return response else rethrow(err) diff --git a/test/runtests.jl b/test/runtests.jl index a1d83dc..b090cf6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -464,6 +464,31 @@ end payload = JSON.parse(String(response.body)) @test payload["error"]["code"] == -32002 @test occursin("not initialized", payload["error"]["message"]) + + stale_session_id = client.session_id + ModelContextProtocol.delete_session!(http_server.server, stale_session_id) + stale_body = JSON.json(Dict( + "jsonrpc" => "2.0", + "id" => "stale-resource", + "method" => "resources/read", + "params" => Dict("uri" => "memory://example"), + )) + stale_response = HTTP.request( + "POST", + client.transport.url; + headers=[ + "Content-Type" => "application/json", + "Accept" => "application/json, text/event-stream", + "MCP-Protocol-Version" => ModelContextProtocol.DEFAULT_PROTOCOL_VERSION, + "MCP-Session-Id" => stale_session_id, + ], + body=stale_body, + status_exception=false, + ) + @test stale_response.status == 404 + stale_payload = JSON.parse(String(stale_response.body)) + @test stale_payload["error"]["code"] == -32001 + @test occursin("Unknown MCP session", stale_payload["error"]["message"]) finally stop_mcp_test_server(http_server) end From 3fadd9e5aa4c4efa0d701f73cf55b807fbb3dcb4 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 29 Jul 2026 21:21:29 -0600 Subject: [PATCH 2/2] test(client): wait for streamed events Replace fixed 200 ms sleeps with bounded condition waits. This matches the deterministic listener test change already present in PR #4 and prevents unrelated platform timing failures from masking the stale-session regression. --- test/runtests.jl | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/runtests.jl b/test/runtests.jl index b090cf6..6ff12f6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -340,8 +340,14 @@ end listener = start_event_listener!(client; poll_interval=0.1) try log_message!(http_server.server; message="test-event", level="warning", session_id=client.session_id) - sleep(0.2) - @test any(evt -> get(evt, "level", "") == "warning" && get(evt, "data", "") == "test-event", log_events) + @test timedwait( + () -> any( + evt -> get(evt, "level", "") == "warning" && get(evt, "data", "") == "test-event", + log_events, + ), + 5.0; + pollint=0.05, + ) == :ok result_level = set_log_level!(client, "debug") @test result_level["level"] == "debug" @@ -356,8 +362,7 @@ end "content" => [Dict("type" => "text", "text" => reverse(String(get(args, "message", ""))))], ), ) - sleep(0.2) - @test !isempty(tool_list_changes) + @test timedwait(() -> !isempty(tool_list_changes), 5.0; pollint=0.05) == :ok paged = list_tools(client; limit=1) @test length(get(paged, "tools", [])) == 1 @@ -376,8 +381,11 @@ end @test isempty(unsub) subscribe_resource(client, "memory://welcome") notify_resource_updated!(http_server.server, "memory://welcome"; annotations=Dict("kind" => "greeting")) - sleep(0.2) - @test any(evt -> evt["uri"] == "memory://welcome", resource_events) + @test timedwait( + () -> any(evt -> evt["uri"] == "memory://welcome", resource_events), + 5.0; + pollint=0.05, + ) == :ok enqueue_server_event!( http_server.server,