# Migrate Ktor extension functions from `Routing` to `Route` (#237) - Replaced `Routing` with `Route` in MCP-related functions for better composability in nested contexts. - Updated tests to verify compatibility with nested routes and subpaths. - Deprecated legacy signatures and removed redundant overloads. ## How Has This Been Tested? Unit test ## Breaking Changes `Routing.mcp(...) `extension was replaced with `Route.mcp(...)` ## Types of changes - [X] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [ ] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context Based on #314 by @IainHull but that PR is too outdated.
This commit is contained in:
parent
b2878ed406
commit
bb79e200c4
3 changed files with 373 additions and 70 deletions
|
|
@ -0,0 +1,68 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.assertions.ktor.client.shouldHaveContentType
|
||||
import io.kotest.assertions.ktor.client.shouldHaveStatus
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.utils.io.readUTF8Line
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
|
||||
@Suppress("AbstractClassCanBeConcreteClass")
|
||||
abstract class AbstractKtorExtensionsTest {
|
||||
|
||||
protected val sseContentType = ContentType("text", "event-stream")
|
||||
|
||||
protected fun testServer() = Server(
|
||||
serverInfo = Implementation(name = "test-server", version = "1.0.0"),
|
||||
options = ServerOptions(capabilities = ServerCapabilities()),
|
||||
)
|
||||
|
||||
/**
|
||||
* Asserts that both MCP transport endpoints are registered at [path]:
|
||||
* - GET returns 200 with `text/event-stream` content type (SSE endpoint)
|
||||
* - POST with a valid MCP payload and session returns 202 Accepted
|
||||
* - POST without a sessionId returns 400 Bad Request
|
||||
*/
|
||||
protected suspend fun HttpClient.assertMcpEndpointsAt(path: String) {
|
||||
prepareGet(path).execute { response ->
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.shouldHaveContentType(sseContentType)
|
||||
|
||||
// Extract sessionId from the SSE "endpoint" event
|
||||
val channel = response.bodyAsChannel()
|
||||
var eventName: String? = null
|
||||
var sessionId: String? = null
|
||||
|
||||
while (sessionId == null && !channel.isClosedForRead) {
|
||||
val line = channel.readUTF8Line() ?: break
|
||||
when {
|
||||
line.startsWith("event:") -> eventName = line.substringAfter("event:").trim()
|
||||
|
||||
line.startsWith("data:") && eventName == "endpoint" -> {
|
||||
val data = line.substringAfter("data:").trim()
|
||||
sessionId = data.substringAfter("sessionId=").ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requireNotNull(sessionId) { "sessionId not found in SSE endpoint event" }
|
||||
|
||||
// POST a valid JSON-RPC ping while the SSE connection is alive
|
||||
val postResponse = post("$path?sessionId=$sessionId") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"jsonrpc":"2.0","id":1,"method":"ping"}""")
|
||||
}
|
||||
postResponse.shouldHaveStatus(HttpStatusCode.Accepted)
|
||||
}
|
||||
|
||||
// POST without sessionId returns 400 Bad Request
|
||||
post(path).shouldHaveStatus(HttpStatusCode.BadRequest)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.assertions.ktor.client.shouldHaveStatus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.testing.testApplication
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* Integration tests for Ktor Application.mcp() extension.
|
||||
*
|
||||
* Verifies that Application.mcp() installs SSE automatically and registers
|
||||
* MCP endpoints at the application root, without requiring explicit install(SSE).
|
||||
*/
|
||||
class KtorApplicationExtensionsTest : AbstractKtorExtensionsTest() {
|
||||
|
||||
/**
|
||||
* Verifies that Application.mcp() does not interfere with other routes
|
||||
* added to the same application.
|
||||
*/
|
||||
@Test
|
||||
fun `Application mcp should installs SSE and coexist with other routes`() = testApplication {
|
||||
application {
|
||||
mcp { testServer() }
|
||||
|
||||
routing {
|
||||
get("/health") { call.respondText("healthy") }
|
||||
}
|
||||
}
|
||||
|
||||
val healthResponse = client.get("/health")
|
||||
healthResponse.shouldHaveStatus(HttpStatusCode.OK)
|
||||
healthResponse.bodyAsText() shouldBe "healthy"
|
||||
|
||||
client.assertMcpEndpointsAt("/")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.assertions.ktor.client.shouldHaveStatus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.sse.SSE
|
||||
import io.ktor.server.testing.testApplication
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* Integration tests for Ktor Route.mcp() extensions.
|
||||
*
|
||||
* Verifies issue #237: Route.mcp() should work on subpaths and with middleware.
|
||||
* The key issue was that Routing.mcp() registered at top-level, preventing use on subpaths.
|
||||
* Now Route.mcp() allows registration on any route path.
|
||||
*/
|
||||
class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
|
||||
|
||||
/**
|
||||
* Verifies that Route.mcp() throws immediately at route registration time
|
||||
* when the SSE plugin has not been installed, rather than failing silently on the first request.
|
||||
*/
|
||||
@Test
|
||||
fun `Route mcp should throw at registration time if SSE plugin is not installed`() {
|
||||
val exception = assertFailsWith<IllegalStateException> {
|
||||
testApplication {
|
||||
application {
|
||||
// Intentionally omit install(SSE)
|
||||
routing {
|
||||
mcp { testServer() }
|
||||
}
|
||||
}
|
||||
client.get("/")
|
||||
}
|
||||
}
|
||||
exception.message shouldContain "SSE"
|
||||
exception.message shouldContain "install"
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that Route.mcp() registers SSE and POST endpoints on the subpath,
|
||||
* not at root, and does not disturb sibling routes.
|
||||
*/
|
||||
@Test
|
||||
fun `Route mcp should register SSE and POST endpoints at the given subpath`() = testApplication {
|
||||
application {
|
||||
install(SSE)
|
||||
|
||||
routing {
|
||||
get("/") { call.respondText("root") }
|
||||
|
||||
route("/api/mcp") {
|
||||
get("/test") { call.respondText("test-endpoint") }
|
||||
mcp { testServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sibling routes are unaffected
|
||||
val rootResponse = client.get("/")
|
||||
rootResponse.shouldHaveStatus(HttpStatusCode.OK)
|
||||
rootResponse.bodyAsText() shouldBe "root"
|
||||
|
||||
val testResponse = client.get("/api/mcp/test")
|
||||
testResponse.shouldHaveStatus(HttpStatusCode.OK)
|
||||
testResponse.bodyAsText() shouldBe "test-endpoint"
|
||||
|
||||
client.assertMcpEndpointsAt("/api/mcp")
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that Route.mcp() registers endpoints at the correct fully-qualified
|
||||
* path when nested inside multiple route() calls.
|
||||
*/
|
||||
@Test
|
||||
fun `Route mcp should register endpoints at the full nested path`() = testApplication {
|
||||
application {
|
||||
install(SSE)
|
||||
|
||||
routing {
|
||||
route("/v1") {
|
||||
route("/services") {
|
||||
route("/mcp") {
|
||||
mcp { testServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.assertMcpEndpointsAt("/v1/services/mcp")
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that mcp(path) registers SSE and POST endpoints at the sub-path
|
||||
* relative to the enclosing route, not at the root.
|
||||
*/
|
||||
@Test
|
||||
fun `Route mcp with path parameter should register endpoints at the resolved path`() = testApplication {
|
||||
application {
|
||||
install(SSE)
|
||||
|
||||
routing {
|
||||
route("/api") {
|
||||
mcp("/mcp-endpoint") { testServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.assertMcpEndpointsAt("/api/mcp-endpoint")
|
||||
|
||||
// Root /api is not an MCP endpoint
|
||||
client.post("/api").shouldHaveStatus(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue