Skip to content

Commit a1d18ec

Browse files
committed
test: Clean up and expand test coverage for MCP route extensions
- Removed `AbstractKtorExtensionsTest` and migrated its helpers to `TestHelpers.kt` for better reuse. - Added new test cases for route and application-level MCP extensions, ensuring default and custom paths work as intended. - Enabled parallel test execution via `junit-platform.properties`. - Updated (reduced) detekt baselines to reflect changes in test structure and layout.
1 parent 3b88496 commit a1d18ec

11 files changed

Lines changed: 449 additions & 404 deletions

kotlin-sdk-server/detekt-baseline-commonMainSourceSet.xml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@
77
<ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$*</ID>
88
<ID>MaxLineLength:ServerSession.kt:ServerSession$"Client requested unsupported protocol version $requestedVersion, falling back to $LATEST_PROTOCOL_VERSION"</ID>
99
<ID>MaxLineLength:ServerSession.kt:ServerSession$"Creating message with ${params.params.messages.size} messages, maxTokens=${params.params.maxTokens}, temperature=${params.params.temperature}, systemPrompt=${if (params.params.systemPrompt != null) "present" else "absent"}"</ID>
10-
<ID>ReturnCount:KtorServer.kt:private suspend fun existingStreamableTransport: StreamableHttpServerTransport?</ID>
1110
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertCapabilityForMethod</ID>
1211
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertNotificationCapability</ID>
1312
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertRequestHandlerCapability</ID>
1413
<ID>TooGenericExceptionCaught:SSEServerTransport.kt:SseServerTransport$e: Exception</ID>
1514
<ID>TooGenericExceptionCaught:Server.kt:Server$e: Exception</ID>
1615
<ID>TooGenericExceptionCaught:StdioServerTransport.kt:StdioServerTransport$e: Throwable</ID>
17-
<ID>TooManyFunctions:KtorServer.kt:io.modelcontextprotocol.kotlin.sdk.server.KtorServer.kt</ID>
1816
<ID>TooManyFunctions:Server.kt:Server</ID>
1917
<ID>TooManyFunctions:ServerSession.kt:ServerSession : Protocol</ID>
2018
</CurrentIssues>

kotlin-sdk-server/detekt-baseline-main.xml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
<ManuallySuppressedIssues/>
44
<CurrentIssues>
55
<ID>InjectDispatcher:FeatureNotificationService.kt:FeatureNotificationService$Default</ID>
6-
<ID>LongParameterList:KtorServer.kt:private suspend fun RoutingContext.streamableTransport: StreamableHttpServerTransport?</ID>
76
<ID>LongParameterList:Server.kt:Server$public fun addTool</ID>
87
<ID>MagicNumber:StdioServerTransport.kt:StdioServerTransport$8192</ID>
98
<ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$"SSEServerTransport already started! If using Server class, note that connect() calls start() automatically."</ID>
@@ -12,14 +11,12 @@
1211
<ID>MaxLineLength:ServerSession.kt:ServerSession$"Creating message with ${params.params.messages.size} messages, maxTokens=${params.params.maxTokens}, temperature=${params.params.temperature}, systemPrompt=${if (params.params.systemPrompt != null) "present" else "absent"}"</ID>
1312
<ID>NoNameShadowing:FeatureNotificationService.kt:FeatureNotificationService${ it.remove(job) }</ID>
1413
<ID>RedundantSuspendModifier:ServerSession.kt:ServerSession$suspend</ID>
15-
<ID>ReturnCount:KtorServer.kt:private suspend fun existingStreamableTransport: StreamableHttpServerTransport?</ID>
1614
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertCapabilityForMethod</ID>
1715
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertNotificationCapability</ID>
1816
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertRequestHandlerCapability</ID>
1917
<ID>TooGenericExceptionCaught:SSEServerTransport.kt:SseServerTransport$e: Exception</ID>
2018
<ID>TooGenericExceptionCaught:Server.kt:Server$e: Exception</ID>
2119
<ID>TooGenericExceptionCaught:StdioServerTransport.kt:StdioServerTransport$e: Throwable</ID>
22-
<ID>TooManyFunctions:KtorServer.kt:io.modelcontextprotocol.kotlin.sdk.server.KtorServer.kt</ID>
2320
<ID>TooManyFunctions:Server.kt:Server</ID>
2421
<ID>TooManyFunctions:ServerSession.kt:ServerSession : Protocol</ID>
2522
<ID>UnsafeCallOnNullableType:StreamableHttpServerTransport.kt:StreamableHttpServerTransport$responseRequestId!!</ID>

kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt

Lines changed: 0 additions & 75 deletions
This file was deleted.

kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,15 @@ import io.ktor.server.testing.testApplication
1212
import kotlin.test.Test
1313

1414
/**
15-
* Integration tests for Ktor Application.mcp() extension.
15+
* Integration tests for [Application.mcp] extension.
1616
*
17-
* Verifies that Application.mcp() installs SSE automatically and registers
18-
* MCP endpoints at the application root, without requiring explicit install(SSE).
17+
* Verifies that [Application.mcp] installs the SSE plugin automatically and registers
18+
* MCP endpoints at the application root, without requiring an explicit `install(SSE)` call.
1919
*/
20-
class KtorApplicationExtensionsTest : AbstractKtorExtensionsTest() {
20+
class KtorApplicationExtensionsTest {
2121

22-
/**
23-
* Verifies that Application.mcp() does not interfere with other routes
24-
* added to the same application.
25-
*/
2622
@Test
27-
fun `Application mcp should installs SSE and coexist with other routes`() = testApplication {
23+
fun `Application mcp should coexist with other routes`() = testApplication {
2824
application {
2925
mcp { testServer() }
3026

kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.modelcontextprotocol.kotlin.sdk.server
22

33
import io.kotest.assertions.ktor.client.shouldHaveStatus
4+
import io.kotest.matchers.nulls.shouldNotBeNull
45
import io.kotest.matchers.shouldBe
56
import io.kotest.matchers.string.shouldContain
67
import io.ktor.client.request.get
@@ -24,7 +25,7 @@ import kotlin.test.assertFailsWith
2425
* The key issue was that Routing.mcp() registered at top-level, preventing use on subpaths.
2526
* Now Route.mcp() allows registration on any route path.
2627
*/
27-
class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
28+
class KtorRouteExtensionsTest {
2829

2930
/**
3031
* Verifies that Route.mcp() throws immediately at route registration time
@@ -43,8 +44,10 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
4344
client.get("/")
4445
}
4546
}
46-
exception.message shouldContain "SSE"
47-
exception.message shouldContain "install"
47+
exception.message shouldNotBeNull {
48+
shouldContain("SSE")
49+
shouldContain("install")
50+
}
4851
}
4952

5053
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package io.modelcontextprotocol.kotlin.sdk.server
2+
3+
import io.kotest.assertions.ktor.client.shouldHaveStatus
4+
import io.kotest.matchers.nulls.shouldNotBeNull
5+
import io.kotest.matchers.shouldBe
6+
import io.kotest.matchers.string.shouldContain
7+
import io.ktor.client.request.get
8+
import io.ktor.client.request.post
9+
import io.ktor.client.statement.bodyAsText
10+
import io.ktor.http.HttpStatusCode
11+
import io.ktor.server.application.install
12+
import io.ktor.server.response.respondText
13+
import io.ktor.server.routing.get
14+
import io.ktor.server.routing.route
15+
import io.ktor.server.routing.routing
16+
import io.ktor.server.sse.SSE
17+
import io.ktor.server.testing.testApplication
18+
import kotlin.test.Test
19+
import kotlin.test.assertFailsWith
20+
21+
class KtorStatelessStreamableHttpRouteExtensionsTest {
22+
23+
@Test
24+
fun `Route mcpStatelessStreamableHttp should throw at registration time if SSE plugin is not installed`() {
25+
val exception = assertFailsWith<IllegalStateException> {
26+
testApplication {
27+
application {
28+
routing {
29+
mcpStatelessStreamableHttp { testServer() }
30+
}
31+
}
32+
client.get("/")
33+
}
34+
}
35+
exception.message shouldNotBeNull {
36+
shouldContain("SSE")
37+
shouldContain("install")
38+
}
39+
}
40+
41+
@Test
42+
fun `Route mcpStatelessStreamableHttp GET and DELETE should return 405 Method Not Allowed`() = testApplication {
43+
application {
44+
install(SSE)
45+
routing {
46+
route("/mcp") {
47+
mcpStatelessStreamableHttp { testServer() }
48+
}
49+
}
50+
}
51+
52+
client.assertStatelessStreamableHttpEndpointsAt("/mcp")
53+
}
54+
55+
@Test
56+
fun `Route mcpStatelessStreamableHttp should register endpoints at the full nested path`() = testApplication {
57+
application {
58+
install(SSE)
59+
routing {
60+
route("/v1") {
61+
route("/mcp") {
62+
mcpStatelessStreamableHttp { testServer() }
63+
}
64+
}
65+
}
66+
}
67+
68+
client.assertStatelessStreamableHttpEndpointsAt("/v1/mcp")
69+
}
70+
71+
@Test
72+
fun `Route mcpStatelessStreamableHttp with path should register endpoints at the resolved subpath`() =
73+
testApplication {
74+
application {
75+
install(SSE)
76+
routing {
77+
route("/api") {
78+
mcpStatelessStreamableHttp("/mcp") { testServer() }
79+
}
80+
}
81+
}
82+
83+
client.assertStatelessStreamableHttpEndpointsAt("/api/mcp")
84+
85+
// The parent route /api is not an MCP endpoint
86+
client.post("/api").shouldHaveStatus(HttpStatusCode.NotFound)
87+
}
88+
89+
@Test
90+
fun `Route mcpStatelessStreamableHttp should not interfere with sibling routes`() = testApplication {
91+
application {
92+
install(SSE)
93+
routing {
94+
get("/health") { call.respondText("ok") }
95+
route("/mcp") {
96+
get("/docs") { call.respondText("docs") }
97+
mcpStatelessStreamableHttp { testServer() }
98+
}
99+
}
100+
}
101+
102+
val healthResponse = client.get("/health")
103+
healthResponse.shouldHaveStatus(HttpStatusCode.OK)
104+
healthResponse.bodyAsText() shouldBe "ok"
105+
106+
val docsResponse = client.get("/mcp/docs")
107+
docsResponse.shouldHaveStatus(HttpStatusCode.OK)
108+
docsResponse.bodyAsText() shouldBe "docs"
109+
110+
client.assertStatelessStreamableHttpEndpointsAt("/mcp")
111+
}
112+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package io.modelcontextprotocol.kotlin.sdk.server
2+
3+
import io.kotest.assertions.ktor.client.shouldHaveStatus
4+
import io.kotest.matchers.shouldBe
5+
import io.ktor.client.request.get
6+
import io.ktor.client.statement.bodyAsText
7+
import io.ktor.http.HttpStatusCode
8+
import io.ktor.server.response.respondText
9+
import io.ktor.server.routing.get
10+
import io.ktor.server.routing.routing
11+
import io.ktor.server.testing.testApplication
12+
import kotlin.test.Test
13+
14+
class KtorStreamableHttpApplicationExtensionsTest {
15+
16+
@Test
17+
fun `Application mcpStreamableHttp should install SSE and register endpoints at default path`() = testApplication {
18+
application {
19+
mcpStreamableHttp { testServer() }
20+
}
21+
22+
client.assertStreamableHttpEndpointsAt("/mcp")
23+
}
24+
25+
@Test
26+
fun `Application mcpStreamableHttp should register endpoints at a custom path`() = testApplication {
27+
application {
28+
mcpStreamableHttp(path = "/api/v1/mcp") { testServer() }
29+
}
30+
31+
client.assertStreamableHttpEndpointsAt("/api/v1/mcp")
32+
33+
// Default path is not registered
34+
client.get("/mcp").shouldHaveStatus(HttpStatusCode.NotFound)
35+
}
36+
37+
@Test
38+
fun `Application mcpStreamableHttp should coexist with other routes`() = testApplication {
39+
application {
40+
mcpStreamableHttp { testServer() }
41+
routing {
42+
get("/health") { call.respondText("healthy") }
43+
}
44+
}
45+
46+
val healthResponse = client.get("/health")
47+
healthResponse.shouldHaveStatus(HttpStatusCode.OK)
48+
healthResponse.bodyAsText() shouldBe "healthy"
49+
50+
client.assertStreamableHttpEndpointsAt("/mcp")
51+
}
52+
53+
@Test
54+
fun `Application mcpStatelessStreamableHttp should install SSE and register endpoints at default path`() =
55+
testApplication {
56+
application {
57+
mcpStatelessStreamableHttp { testServer() }
58+
}
59+
60+
client.assertStatelessStreamableHttpEndpointsAt("/mcp")
61+
}
62+
63+
@Test
64+
fun `Application mcpStatelessStreamableHttp should register endpoints at a custom path`() = testApplication {
65+
application {
66+
mcpStatelessStreamableHttp(path = "/api/v1/mcp") { testServer() }
67+
}
68+
69+
client.assertStatelessStreamableHttpEndpointsAt("/api/v1/mcp")
70+
71+
// Default path is not registered
72+
client.get("/mcp").shouldHaveStatus(HttpStatusCode.NotFound)
73+
}
74+
}

0 commit comments

Comments
 (0)