Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions src/transports/http.jl
Original file line number Diff line number Diff line change
Expand Up @@ -281,12 +281,18 @@ function handle_request(transport::HttpTransport, stream::HTTP.Stream)
# Handle SSE stream
handle_sse_stream(transport, stream, stream_id)
else
HTTP.setstatus(stream, 406)
HTTP.setheader(stream, "Content-Type" => "text/plain")
error_msg = "Not Acceptable - GET requests must Accept: text/event-stream"
HTTP.setheader(stream, "Content-Length" => string(length(error_msg)))
# Return health check response for plain GET requests (no Accept: text/event-stream)
# This allows clients like Claude Code to perform health checks
HTTP.setstatus(stream, 200)
HTTP.setheader(stream, "Content-Type" => "application/json")
health_response = JSON3.write(Dict(
"status" => "ok",
"protocol_version" => transport.protocol_version,
"session_id" => transport.session_id
))
HTTP.setheader(stream, "Content-Length" => string(length(health_response)))
HTTP.startwrite(stream)
write(stream, error_msg)
write(stream, health_response)
end
return nothing
end
Expand Down Expand Up @@ -338,16 +344,12 @@ function handle_request(transport::HttpTransport, stream::HTTP.Stream)
return nothing
end

# Check Accept header per 2025-06-18 spec - MUST include both application/json and text/event-stream
# Check Accept header per 2025-06-18 spec - SHOULD include both application/json and text/event-stream
# Made lenient to support clients like Claude Code that may not send correct Accept headers
accept_header = HTTP.header(request, "Accept", "")
if !contains(accept_header, "application/json") || !contains(accept_header, "text/event-stream")
HTTP.setstatus(stream, 406)
HTTP.setheader(stream, "Content-Type" => "text/plain")
error_msg = "Not Acceptable: Must accept both application/json and text/event-stream"
HTTP.setheader(stream, "Content-Length" => string(length(error_msg)))
HTTP.startwrite(stream)
write(stream, error_msg)
return nothing
@debug "Client Accept header doesn't meet spec requirements" accept=accept_header expected="application/json, text/event-stream"
# Continue anyway - the spec requirement is relaxed for compatibility
end

# Check for session ID header
Expand Down
132 changes: 132 additions & 0 deletions test/transports/test_http.jl
Original file line number Diff line number Diff line change
Expand Up @@ -343,4 +343,136 @@
end
Base.close(timer)
end

@testset "Health Check (Plain GET)" begin
# Test that plain GET requests (without Accept: text/event-stream) return health status
# This is needed for clients like Claude Code that perform simple health checks
port = 12090 + rand(1:1000)

transport = HttpTransport(port=port)

server = mcp_server(
name = "health-check-server",
version = "1.0.0"
)

server.transport = transport
ModelContextProtocol.connect(transport)
server_task = @async start!(server)
sleep(2)

# Plain GET without Accept: text/event-stream should return health status
response = HTTP.get(
"http://127.0.0.1:$port/",
["Accept" => "application/json"] # Not text/event-stream
)

@test response.status == 200
@test HTTP.header(response, "Content-Type") == "application/json"

result = JSON3.read(String(response.body))
@test result["status"] == "ok"
@test haskey(result, "protocol_version")
@test result["protocol_version"] == "2025-06-18"

# Also test with no Accept header at all
response = HTTP.get("http://127.0.0.1:$port/")

@test response.status == 200
result = JSON3.read(String(response.body))
@test result["status"] == "ok"

# Clean up
server.active = false
ModelContextProtocol.close(transport)

timer = Timer(2)
while !istaskdone(server_task) && isopen(timer)
sleep(0.1)
end
Base.close(timer)
end

@testset "Lenient Accept Header for POST" begin
# Test that POST requests work even without proper Accept header
# This is needed for clients like Claude Code that may not send correct headers
port = 13090 + rand(1:1000)

transport = HttpTransport(port=port)

test_tool = MCPTool(
name = "test_tool",
description = "Test tool",
handler = function(params)
return TextContent(text = "success")
end,
parameters = []
)

server = mcp_server(
name = "lenient-header-server",
version = "1.0.0",
tools = [test_tool]
)

server.transport = transport
ModelContextProtocol.connect(transport)
server_task = @async start!(server)
sleep(2)

# POST with only application/json Accept (missing text/event-stream)
response = HTTP.post(
"http://127.0.0.1:$port/",
["Content-Type" => "application/json",
"Accept" => "application/json"], # Missing text/event-stream
JSON3.write(Dict(
"jsonrpc" => "2.0",
"method" => "initialize",
"params" => Dict(
"protocolVersion" => "2025-06-18",
"capabilities" => Dict(),
"clientInfo" => Dict("name" => "test", "version" => "1.0")
),
"id" => 1
))
)

# Should succeed despite non-compliant Accept header
@test response.status == 200
result = JSON3.read(String(response.body))
@test result["jsonrpc"] == "2.0"
@test result["id"] == 1
@test haskey(result, "result")

session_id = HTTP.header(response, "Mcp-Session-Id", "")

# Also test with */* Accept header
response = HTTP.post(
"http://127.0.0.1:$port/",
["Content-Type" => "application/json",
"Mcp-Session-Id" => session_id,
"Accept" => "*/*"], # Wildcard accept
JSON3.write(Dict(
"jsonrpc" => "2.0",
"method" => "tools/list",
"params" => Dict(),
"id" => 2
))
)

@test response.status == 200
result = JSON3.read(String(response.body))
@test result["id"] == 2
@test length(result["result"]["tools"]) == 1

# Clean up
server.active = false
ModelContextProtocol.close(transport)

timer = Timer(2)
while !istaskdone(server_task) && isopen(timer)
sleep(0.1)
end
Base.close(timer)
end
end