Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.append
import io.ktor.http.isSuccess
import io.ktor.http.protocolWithAuthority
Expand Down Expand Up @@ -142,12 +143,21 @@ public class SseClientTransport(

/**
* Resolves and completes [endpoint] based on [eventData].
* Uses full URLs as-is, treats absolute paths as origin-relative,
* and relative paths as relative to [baseUrl].
* Uses full URLs as-is, but rejects those whose origin differs from the SSE connection origin,
* treats absolute paths as origin-relative, and relative paths as relative to [baseUrl].
*/
private fun handleEndpoint(eventData: String) {
try {
val endpointUrl = if (eventData.startsWith("http://") || eventData.startsWith("https://")) {
val endpointOrigin = Url(eventData)
if (!endpointOrigin.hasSameOrigin()) {
val error = IllegalArgumentException(
"Endpoint origin ${endpointOrigin.protocolWithAuthority} does not match connection origin $origin",
)
_onError(error)
endpoint.completeExceptionally(error)
return
}
eventData
} else if (eventData.startsWith("/")) {
origin + eventData
Expand All @@ -163,6 +173,13 @@ public class SseClientTransport(
}
}

/**
* Returns true when [this]'s origin (scheme, host, and port) matches the SSE connection's origin.
* The comparison uses the authority string, so default ports are ignored (e.g. `http://host:80`
* and `http://host` are considered the same origin).
*/
private fun Url.hasSameOrigin(): Boolean = protocolWithAuthority == origin

private suspend fun handleMessage(data: String) {
try {
val message = McpJson.decodeFromString<JSONRPCMessage>(data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.time.Duration.Companion.seconds

class SseClientTransportTest {
Expand Down Expand Up @@ -67,12 +68,38 @@ class SseClientTransportTest {
}

@Test
fun `full url endpoint is used as-is`() = runTest {
fun `full url endpoint with a different origin is rejected`() = runTest {
// Given
val sseUrl = "http://example.com/api/mcp/sse"

// And
val endpointEvent = "https://example.com/messages?sessionId=abc"
val endpointEvent = "https://evil.example.com/messages?sessionId=abc"

// And
val engine = CapturingSseClientEngine(endpoint = endpointEvent)
val transport = sseTransport(sseUrl, engine)

// When
val exception = assertFailsWith<IllegalArgumentException> {
transport.start()
}

// Then
exception.message shouldBe "Endpoint origin https://evil.example.com does not match connection origin http://example.com"
engine.capturedPosts shouldHaveSize 0

// Cleanup
transport.close()
engine.close()
}

@Test
fun `full url endpoint with the same origin is used as-is`() = runTest {
// Given
val sseUrl = "http://example.com/api/mcp/sse"

// And
val endpointEvent = "http://example.com/messages?sessionId=abc"

// And
val engine = CapturingSseClientEngine(endpoint = endpointEvent)
Expand All @@ -85,7 +112,7 @@ class SseClientTransportTest {
// Then
val capturedPosts = engine.capturedPosts
capturedPosts shouldHaveSize 1
capturedPosts[0].url.toString() shouldBe "https://example.com/messages?sessionId=abc"
capturedPosts[0].url.toString() shouldBe "http://example.com/messages?sessionId=abc"

// Cleanup
transport.close()
Expand Down