Skip to content

Commit 48c19c2

Browse files
authored
Document Client usage with HTTPClientTransport (#77)
* Add test coverage for Client with HTTPClientTransport * Configure HTTP client transport to stream by default * Add example usage of streaming HTTP transport to README * Explicitly set streaming to false in test
1 parent ccb070f commit 48c19c2

File tree

3 files changed

+222
-20
lines changed

3 files changed

+222
-20
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,29 @@ try await client.connect(transport: transport)
3939
let result = try await client.initialize()
4040
```
4141

42+
### Streaming HTTP Transport
43+
44+
The HTTP transport supports streaming mode for real-time communication using Server-Sent Events (SSE):
45+
46+
```swift
47+
import MCP
48+
49+
// Create a streaming HTTP transport
50+
let transport = HTTPClientTransport(
51+
endpoint: URL(string: "http://localhost:8080")!,
52+
)
53+
54+
// Initialize the client with streaming transport
55+
let client = Client(name: "MyApp", version: "1.0.0")
56+
try await client.connect(transport: transport)
57+
58+
// Initialize the connection
59+
let result = try await client.initialize()
60+
61+
// The transport will automatically handle SSE events
62+
// and deliver them through the client's notification handlers
63+
```
64+
4265
### Basic Server Setup
4366

4467
```swift

Sources/MCP/Base/Transports/HTTPClientTransport.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public actor HTTPClientTransport: Actor, Transport {
2121
public init(
2222
endpoint: URL,
2323
configuration: URLSessionConfiguration = .default,
24-
streaming: Bool = false,
24+
streaming: Bool = true,
2525
logger: Logger? = nil
2626
) {
2727
self.init(
@@ -269,7 +269,7 @@ public actor HTTPClientTransport: Actor, Transport {
269269
if line.hasSuffix("\r") {
270270
line = line.dropLast()
271271
}
272-
272+
273273
// Lines starting with ":" are comments
274274
if line.hasPrefix(":") { continue }
275275

Tests/MCPTests/HTTPClientTransportTests.swift

Lines changed: 197 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ import Testing
5151

5252
func executeHandler(for request: URLRequest) async throws -> (HTTPURLResponse, Data) {
5353
guard let handler = requestHandler else {
54-
throw MockURLProtocolError.noRequestHandler
54+
throw NSError(
55+
domain: "MockURLProtocolError", code: 0,
56+
userInfo: [
57+
NSLocalizedDescriptionKey: "No request handler set"
58+
])
5559
}
5660
return try await handler(request)
5761
}
@@ -123,11 +127,6 @@ import Testing
123127
override func stopLoading() {}
124128
}
125129

126-
enum MockURLProtocolError: Swift.Error {
127-
case noRequestHandler
128-
case invalidURL
129-
}
130-
131130
// MARK: -
132131

133132
@Suite("HTTP Client Transport Tests", .serialized)
@@ -140,7 +139,11 @@ import Testing
140139
configuration.protocolClasses = [MockURLProtocol.self]
141140

142141
let transport = HTTPClientTransport(
143-
endpoint: testEndpoint, configuration: configuration, streaming: false, logger: nil)
142+
endpoint: testEndpoint,
143+
configuration: configuration,
144+
streaming: false,
145+
logger: nil
146+
)
144147

145148
try await transport.connect()
146149
await transport.disconnect()
@@ -152,7 +155,11 @@ import Testing
152155
configuration.protocolClasses = [MockURLProtocol.self]
153156

154157
let transport = HTTPClientTransport(
155-
endpoint: testEndpoint, configuration: configuration, streaming: false, logger: nil)
158+
endpoint: testEndpoint,
159+
configuration: configuration,
160+
streaming: false,
161+
logger: nil
162+
)
156163
try await transport.connect()
157164

158165
let messageData = #"{"jsonrpc":"2.0","method":"initialize","id":1}"#.data(using: .utf8)!
@@ -190,7 +197,11 @@ import Testing
190197
configuration.protocolClasses = [MockURLProtocol.self]
191198

192199
let transport = HTTPClientTransport(
193-
endpoint: testEndpoint, configuration: configuration, streaming: false, logger: nil)
200+
endpoint: testEndpoint,
201+
configuration: configuration,
202+
streaming: false,
203+
logger: nil
204+
)
194205
try await transport.connect()
195206

196207
let messageData = #"{"jsonrpc":"2.0","method":"initialize","id":1}"#.data(using: .utf8)!
@@ -220,7 +231,11 @@ import Testing
220231
configuration.protocolClasses = [MockURLProtocol.self]
221232

222233
let transport = HTTPClientTransport(
223-
endpoint: testEndpoint, configuration: configuration, streaming: false, logger: nil)
234+
endpoint: testEndpoint,
235+
configuration: configuration,
236+
streaming: false,
237+
logger: nil
238+
)
224239
try await transport.connect()
225240

226241
let initialSessionID = "existing-session-abc"
@@ -265,7 +280,11 @@ import Testing
265280
configuration.protocolClasses = [MockURLProtocol.self]
266281

267282
let transport = HTTPClientTransport(
268-
endpoint: testEndpoint, configuration: configuration)
283+
endpoint: testEndpoint,
284+
configuration: configuration,
285+
streaming: false,
286+
logger: nil
287+
)
269288
try await transport.connect()
270289

271290
let messageData = #"{"jsonrpc":"2.0","method":"test","id":3}"#.data(using: .utf8)!
@@ -298,7 +317,11 @@ import Testing
298317
configuration.protocolClasses = [MockURLProtocol.self]
299318

300319
let transport = HTTPClientTransport(
301-
endpoint: testEndpoint, configuration: configuration)
320+
endpoint: testEndpoint,
321+
configuration: configuration,
322+
streaming: false,
323+
logger: nil
324+
)
302325
try await transport.connect()
303326

304327
let messageData = #"{"jsonrpc":"2.0","method":"test","id":4}"#.data(using: .utf8)!
@@ -331,7 +354,11 @@ import Testing
331354
configuration.protocolClasses = [MockURLProtocol.self]
332355

333356
let transport = HTTPClientTransport(
334-
endpoint: testEndpoint, configuration: configuration)
357+
endpoint: testEndpoint,
358+
configuration: configuration,
359+
streaming: false,
360+
logger: nil
361+
)
335362
try await transport.connect()
336363

337364
let initialSessionID = "expired-session-xyz"
@@ -385,8 +412,11 @@ import Testing
385412
configuration.protocolClasses = [MockURLProtocol.self]
386413

387414
let transport = HTTPClientTransport(
388-
endpoint: testEndpoint, configuration: configuration, streaming: true,
389-
logger: nil)
415+
endpoint: testEndpoint,
416+
configuration: configuration,
417+
streaming: true,
418+
logger: nil
419+
)
390420

391421
let eventString = "id: event1\ndata: {\"key\":\"value\"}\n\n"
392422
let sseEventData = eventString.data(using: .utf8)!
@@ -419,8 +449,11 @@ import Testing
419449
configuration.protocolClasses = [MockURLProtocol.self]
420450

421451
let transport = HTTPClientTransport(
422-
endpoint: testEndpoint, configuration: configuration, streaming: true,
423-
logger: nil)
452+
endpoint: testEndpoint,
453+
configuration: configuration,
454+
streaming: true,
455+
logger: nil
456+
)
424457

425458
let eventString = "id: event1\r\ndata: {\"key\":\"value\"}\r\n\n"
426459
let sseEventData = eventString.data(using: .utf8)!
@@ -448,6 +481,152 @@ import Testing
448481
#expect(receivedData == expectedData)
449482
}
450483
#endif // !canImport(FoundationNetworking)
451-
}
452484

485+
@Test(
486+
"Client with HTTP Transport complete flow", .httpClientTransportSetup,
487+
.timeLimit(.minutes(1)))
488+
func testClientFlow() async throws {
489+
let configuration = URLSessionConfiguration.ephemeral
490+
configuration.protocolClasses = [MockURLProtocol.self]
491+
492+
let transport = HTTPClientTransport(
493+
endpoint: testEndpoint,
494+
configuration: configuration,
495+
streaming: false,
496+
logger: nil
497+
)
498+
499+
let client = Client(name: "TestClient", version: "1.0.0")
500+
501+
// Use an actor to track request sequence
502+
actor RequestTracker {
503+
enum RequestType {
504+
case initialize
505+
case callTool
506+
}
507+
508+
private(set) var lastRequest: RequestType?
509+
510+
func setRequest(_ type: RequestType) {
511+
lastRequest = type
512+
}
513+
514+
func getLastRequest() -> RequestType? {
515+
return lastRequest
516+
}
517+
}
518+
519+
let tracker = RequestTracker()
520+
521+
// Setup mock responses
522+
await MockURLProtocol.requestHandlerStorage.setHandler {
523+
[testEndpoint, tracker] (request: URLRequest) in
524+
switch request.httpMethod {
525+
case "GET":
526+
#expect(
527+
request.allHTTPHeaderFields?["Accept"]?.contains("text/event-stream")
528+
== true)
529+
case "POST":
530+
#expect(
531+
request.allHTTPHeaderFields?["Accept"]?.contains("application/json") == true
532+
)
533+
default:
534+
Issue.record(
535+
"Unsupported HTTP method \(String(describing: request.httpMethod))")
536+
}
537+
538+
#expect(request.url == testEndpoint)
539+
540+
let bodyData = request.readBody()
541+
542+
guard let bodyData = bodyData,
543+
let json = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any],
544+
let method = json["method"] as? String
545+
else {
546+
throw NSError(
547+
domain: "MockURLProtocolError", code: 0,
548+
userInfo: [
549+
NSLocalizedDescriptionKey: "Invalid JSON-RPC message \(#file):\(#line)"
550+
])
551+
}
552+
553+
if method == "initialize" {
554+
await tracker.setRequest(.initialize)
555+
556+
let requestID = json["id"] as! String
557+
let result = Initialize.Result(
558+
protocolVersion: Version.latest,
559+
capabilities: .init(tools: .init()),
560+
serverInfo: .init(name: "Mock Server", version: "0.0.1"),
561+
instructions: nil
562+
)
563+
let response = Initialize.response(id: .string(requestID), result: result)
564+
let responseData = try JSONEncoder().encode(response)
565+
566+
let httpResponse = HTTPURLResponse(
567+
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
568+
headerFields: ["Content-Type": "application/json"])!
569+
return (httpResponse, responseData)
570+
} else if method == "tools/call" {
571+
// Verify initialize was called first
572+
if let lastRequest = await tracker.getLastRequest(), lastRequest != .initialize
573+
{
574+
#expect(Bool(false), "Initialize should be called before callTool")
575+
}
576+
577+
await tracker.setRequest(.callTool)
578+
579+
let params = json["params"] as? [String: Any]
580+
let toolName = params?["name"] as? String
581+
#expect(toolName == "calculator")
582+
583+
let requestID = json["id"] as! String
584+
let result = CallTool.Result(content: [.text("42")])
585+
let response = CallTool.response(id: .string(requestID), result: result)
586+
let responseData = try JSONEncoder().encode(response)
587+
588+
let httpResponse = HTTPURLResponse(
589+
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
590+
headerFields: ["Content-Type": "application/json"])!
591+
return (httpResponse, responseData)
592+
} else if method == "notifications/initialized" {
593+
// Ignore initialized notifications
594+
let httpResponse = HTTPURLResponse(
595+
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
596+
headerFields: ["Content-Type": "application/json"])!
597+
return (httpResponse, Data())
598+
} else {
599+
throw NSError(
600+
domain: "MockURLProtocolError", code: 0,
601+
userInfo: [
602+
NSLocalizedDescriptionKey:
603+
"Unexpected request method: \(method) \(#file):\(#line)"
604+
])
605+
}
606+
}
607+
608+
// Execute the complete flow
609+
try await client.connect(transport: transport)
610+
611+
// Step 1: Initialize client
612+
let initResult = try await client.initialize()
613+
#expect(initResult.protocolVersion == Version.latest)
614+
#expect(initResult.capabilities.tools != nil)
615+
616+
// Step 2: Call a tool
617+
let toolResult = try await client.callTool(name: "calculator")
618+
#expect(toolResult.content.count == 1)
619+
if case let .text(text) = toolResult.content[0] {
620+
#expect(text == "42")
621+
} else {
622+
#expect(Bool(false), "Expected text content")
623+
}
624+
625+
// Step 3: Verify request sequence
626+
#expect(await tracker.getLastRequest() == .callTool)
627+
628+
// Step 4: Disconnect
629+
await client.disconnect()
630+
}
631+
}
453632
#endif // swift(>=6.1)

0 commit comments

Comments
 (0)