Skip to content

Commit f6e401a

Browse files
authored
Fix event stream line parsing to handle CRLF and empty lines correctly (#89)
* Optimized code in `connectToEventStream` to use `.lines` computed value of `AsyncSequence` to produce a `AsyncLineSequence` instead of building a lines manually. * - Reverter use of `lines` (it swallows newlines) - Fiexed original issue I experienced with line 229 `while let newlineIndex = buffer.firstIndex(of: "\n") {` not handling "\r\n" properly - Added unit test to test for "\r\n" scenario - Simplified `end of event` test - `line` creation trims newline, added code trims carriage return. Only thing that should be left is an empty line
1 parent 99517b0 commit f6e401a

2 files changed

Lines changed: 45 additions & 3 deletions

File tree

Sources/MCP/Base/Transports/HTTPClientTransport.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,16 @@ public actor HTTPClientTransport: Actor, Transport {
226226
buffer.append(char)
227227

228228
// Process complete lines
229-
while let newlineIndex = buffer.firstIndex(of: "\n") {
230-
let line = buffer[..<newlineIndex]
229+
while let newlineIndex = buffer.utf8.firstIndex(where: { $0 == 10 }) {
230+
var line = buffer[..<newlineIndex]
231+
if line.hasSuffix("\r") {
232+
line = line.dropLast()
233+
}
234+
231235
buffer = String(buffer[buffer.index(after: newlineIndex)...])
232236

233237
// Empty line marks the end of an event
234-
if line.isEmpty || line == "\r" || line == "\n" || line == "\r\n" {
238+
if line.isEmpty {
235239
if !eventData.isEmpty {
236240
// Process the event
237241
if eventType == "id" {
@@ -256,6 +260,10 @@ public actor HTTPClientTransport: Actor, Transport {
256260
continue
257261
}
258262

263+
if line.hasSuffix("\r") {
264+
line = line.dropLast()
265+
}
266+
259267
// Lines starting with ":" are comments
260268
if line.hasPrefix(":") { continue }
261269

Tests/MCPTests/HTTPClientTransportTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,40 @@ import Testing
411411
let expectedData = #"{"key":"value"}"#.data(using: .utf8)!
412412
let receivedData = try await iterator.next()
413413

414+
#expect(receivedData == expectedData)
415+
}
416+
@Test("Receive Server-Sent Event (SSE) (CR-NL)", .httpClientTransportSetup)
417+
func testReceiveSSE_CRNL() async throws {
418+
let configuration = URLSessionConfiguration.ephemeral
419+
configuration.protocolClasses = [MockURLProtocol.self]
420+
421+
let transport = HTTPClientTransport(
422+
endpoint: testEndpoint, configuration: configuration, streaming: true,
423+
logger: nil)
424+
425+
let eventString = "id: event1\r\ndata: {\"key\":\"value\"}\r\n\n"
426+
let sseEventData = eventString.data(using: .utf8)!
427+
428+
await MockURLProtocol.requestHandlerStorage.setHandler {
429+
[testEndpoint] (request: URLRequest) in
430+
#expect(request.url == testEndpoint)
431+
#expect(request.httpMethod == "GET")
432+
#expect(request.value(forHTTPHeaderField: "Accept") == "text/event-stream")
433+
let response = HTTPURLResponse(
434+
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
435+
headerFields: ["Content-Type": "text/event-stream"])!
436+
return (response, sseEventData)
437+
}
438+
439+
try await transport.connect()
440+
try await Task.sleep(for: .milliseconds(100))
441+
442+
let stream = await transport.receive()
443+
var iterator = stream.makeAsyncIterator()
444+
445+
let expectedData = #"{"key":"value"}"#.data(using: .utf8)!
446+
let receivedData = try await iterator.next()
447+
414448
#expect(receivedData == expectedData)
415449
}
416450
#endif // !canImport(FoundationNetworking)

0 commit comments

Comments
 (0)