1+ package io.modelcontextprotocol.kotlin.sdk.client
2+
3+ import io.github.oshai.kotlinlogging.KotlinLogging
4+ import io.ktor.client.HttpClient
5+ import io.ktor.client.plugins.sse.ClientSSESession
6+ import io.ktor.client.plugins.sse.sseSession
7+ import io.ktor.client.request.HttpRequestBuilder
8+ import io.ktor.client.request.post
9+ import io.ktor.client.request.setBody
10+ import io.ktor.client.statement.bodyAsText
11+ import io.ktor.http.ContentType
12+ import io.ktor.http.HttpHeaders
13+ import io.ktor.http.append
14+ import io.ktor.http.contentType
15+ import io.ktor.http.isSuccess
16+ import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
17+ import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
18+ import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
19+ import kotlinx.coroutines.CoroutineName
20+ import kotlinx.coroutines.CoroutineScope
21+ import kotlinx.coroutines.Job
22+ import kotlinx.coroutines.SupervisorJob
23+ import kotlinx.coroutines.cancel
24+ import kotlinx.coroutines.cancelAndJoin
25+ import kotlinx.coroutines.launch
26+ import kotlin.concurrent.atomics.AtomicBoolean
27+ import kotlin.concurrent.atomics.ExperimentalAtomicApi
28+
29+ private val logger = KotlinLogging .logger {}
30+
31+ /* *
32+ * Client transport for Streamable HTTP: this will send messages via HTTP POST requests
33+ * and optionally receive streaming responses via SSE.
34+ *
35+ * This implements the Streamable HTTP transport as specified in MCP 2024-11-05.
36+ */
37+ @OptIn(ExperimentalAtomicApi ::class )
38+ public class StreamableHttpClientTransport (
39+ private val client : HttpClient ,
40+ private val url : String ,
41+ private val requestBuilder : HttpRequestBuilder .() -> Unit = {},
42+ ) : AbstractTransport() {
43+
44+ private val initialized: AtomicBoolean = AtomicBoolean (false )
45+ private var sseSession: ClientSSESession ? = null
46+ private val scope by lazy { CoroutineScope (SupervisorJob ()) }
47+ private var sseJob: Job ? = null
48+ private var sessionId: String? = null
49+
50+ override suspend fun start () {
51+ if (! initialized.compareAndSet(expectedValue = false , newValue = true )) {
52+ error(" StreamableHttpClientTransport already started!" )
53+ }
54+ logger.debug { " Client transport starting..." }
55+ startSseSession()
56+ }
57+
58+ private suspend fun startSseSession () {
59+ logger.debug { " Client attempting to start SSE session at url: $url " }
60+ try {
61+ sseSession = client.sseSession(
62+ urlString = url,
63+ block = requestBuilder,
64+ )
65+ logger.debug { " Client SSE session started successfully." }
66+
67+ sseJob = scope.launch(CoroutineName (" StreamableHttpTransport.collect#${hashCode()} " )) {
68+ sseSession?.incoming?.collect { event ->
69+ logger.trace { " Client received SSE event: event=${event.event} , data=${event.data} " }
70+ when (event.event) {
71+ " error" -> {
72+ val e = IllegalStateException (" SSE error: ${event.data} " )
73+ logger.error(e) { " SSE stream reported an error event." }
74+ _onError (e)
75+ }
76+
77+ else -> {
78+ // All non-error events are treated as JSON-RPC messages
79+ try {
80+ val eventData = event.data
81+ if (! eventData.isNullOrEmpty()) {
82+ val message = McpJson .decodeFromString<JSONRPCMessage >(eventData)
83+ _onMessage (message)
84+ }
85+ } catch (e: Exception ) {
86+ logger.error(e) { " Error processing SSE message" }
87+ _onError (e)
88+ }
89+ }
90+ }
91+ }
92+ }
93+ } catch (e: Exception ) {
94+ // SSE session is optional, don't fail if it can't be established
95+ // The server might not support GET requests for SSE
96+ logger.warn(e) { " Client failed to start SSE session. This may be expected if the server does not support GET." }
97+ _onError (e)
98+ }
99+ }
100+
101+ override suspend fun send (message : JSONRPCMessage ) {
102+ logger.debug { " Client sending message via POST to $url : ${McpJson .encodeToString(message)} " }
103+ try {
104+ val response = client.post(url) {
105+ requestBuilder()
106+ contentType(ContentType .Application .Json )
107+ headers.append(HttpHeaders .Accept , " ${ContentType .Application .Json } , ${ContentType .Text .EventStream } " )
108+
109+ // Add session ID if we have one
110+ sessionId?.let {
111+ headers.append(" Mcp-Session-Id" , it)
112+ }
113+
114+ setBody(McpJson .encodeToString(message))
115+ }
116+ logger.debug { " Client received POST response: ${response.status} " }
117+
118+ if (! response.status.isSuccess()) {
119+ val text = response.bodyAsText()
120+ val error = Exception (" HTTP ${response.status} : $text " )
121+ logger.error(error) { " Client POST request failed." }
122+ _onError (error)
123+ throw error
124+ }
125+
126+ // Extract session ID from response headers if present
127+ response.headers[" Mcp-Session-Id" ]?.let {
128+ sessionId = it
129+ }
130+
131+ // Handle response based on content type
132+ when (response.contentType()?.contentType) {
133+ ContentType .Application .Json .contentType -> {
134+ // Single JSON response
135+ val responseBody = response.bodyAsText()
136+ logger.trace { " Client processing JSON response: $responseBody " }
137+ if (responseBody.isNotEmpty()) {
138+ try {
139+ val responseMessage = McpJson .decodeFromString<JSONRPCMessage >(responseBody)
140+ _onMessage (responseMessage)
141+ } catch (e: Exception ) {
142+ logger.error(e) { " Error processing JSON response" }
143+ _onError (e)
144+ }
145+ }
146+ }
147+
148+ ContentType .Text .EventStream .contentType -> {
149+ logger.trace { " Client received SSE stream in POST response. Messages will be handled by the main SSE session." }
150+ }
151+
152+ else -> {
153+ logger.trace { " Client received response with unexpected or no content type: ${response.contentType()} " }
154+ }
155+ }
156+ } catch (e: Exception ) {
157+ logger.error(e) { " Client send failed." }
158+ _onError (e)
159+ throw e
160+ }
161+ }
162+
163+ override suspend fun close () {
164+ if (! initialized.load()) {
165+ return // Already closed or never started
166+ }
167+ logger.debug { " Client transport closing." }
168+
169+ try {
170+ sseSession?.cancel()
171+ sseJob?.cancelAndJoin()
172+ scope.cancel()
173+ } catch (e: Exception ) {
174+ // Ignore errors during cleanup
175+ } finally {
176+ _onClose ()
177+ }
178+ }
179+ }
0 commit comments