Skip to content

Commit 7c93a68

Browse files
committed
feat: Refactor MCP route extensions
- Move `TransportManager` to separate file and generalize. - Refactored `mcpStreamableHttp` and `mcpStatelessStreamableHttp` to use config object. - Added integration tests (`KtorStreamableHttpExtensionsTest`) to validate route registrations, subpath handling, and sibling route isolation. fix: Correct Ktor external documentation link and update KDoc references - Updated external documentation link from `ktor-client` to `ktor` in Dokka configuration. - Replaced KDoc references to `io.ktor.server.auth.authenticate` with `Route.authenticate` for consistency.
1 parent 58dfa97 commit 7c93a68

7 files changed

Lines changed: 631 additions & 205 deletions

File tree

buildSrc/src/main/kotlin/mcp.dokka.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ dokka {
2121

2222
documentedVisibilities(VisibilityModifier.Public)
2323

24-
externalDocumentationLinks.register("ktor-client") {
25-
url("https://api.ktor.io/ktor-client/")
24+
externalDocumentationLinks.register("ktor") {
25+
url("https://api.ktor.io/")
2626
packageListUrl("https://api.ktor.io/package-list")
2727
}
2828

integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/AbstractAuthenticationTest.kt

Lines changed: 62 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
2929
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
3030
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
3131
import io.modelcontextprotocol.kotlin.test.utils.actualPort
32-
import kotlinx.coroutines.Dispatchers
3332
import kotlinx.coroutines.runBlocking
3433
import java.util.UUID
3534
import kotlin.test.Test
@@ -39,10 +38,6 @@ import io.ktor.server.sse.SSE as ServerSSE
3938

4039
/**
4140
* Base class for MCP authentication integration tests.
42-
*
43-
* This class provides a common setup for testing MCP servers behind Ktor authentication.
44-
* It verifies that unauthenticated requests are rejected and that the authenticated principal
45-
* is accessible within MCP resource handlers.
4641
*/
4742
abstract class AbstractAuthenticationTest {
4843

@@ -57,10 +52,6 @@ abstract class AbstractAuthenticationTest {
5752

5853
/**
5954
* Installs Ktor plugins required by the transport under test.
60-
*
61-
* The default installs [ServerSSE] (required by both SSE and StreamableHttp transports)
62-
* and [ContentNegotiation] with [McpJson] (required by StreamableHttp for JSON body
63-
* serialization). Subclasses may override to add transport-specific plugins.
6455
*/
6556
protected open fun Application.configurePlugins() {
6657
install(ServerSSE)
@@ -71,7 +62,6 @@ abstract class AbstractAuthenticationTest {
7162

7263
/**
7364
* Registers the MCP server on the given route.
74-
* Concrete implementations should use transport-specific extensions (e.g., [Route.mcp] for SSE).
7565
*/
7666
abstract fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server)
7767

@@ -81,90 +71,86 @@ abstract class AbstractAuthenticationTest {
8171
abstract fun createClientTransport(baseUrl: String, user: String, pass: String): Transport
8272

8373
@Test
84-
fun `mcp behind basic auth rejects unauthenticated requests with 401`() {
85-
runBlocking(Dispatchers.IO) {
86-
val server = embeddedServer(ServerCIO, host = HOST, port = 0) {
87-
configurePlugins()
88-
install(Authentication) {
89-
basic(AUTH_REALM) {
90-
validate { credentials ->
91-
if (credentials.name == validUser && credentials.password == validPassword) {
92-
UserIdPrincipal(credentials.name)
93-
} else {
94-
null
95-
}
74+
fun `mcp behind basic auth rejects unauthenticated requests with 401`(): Unit = runBlocking {
75+
val server = embeddedServer(ServerCIO, host = HOST, port = 0) {
76+
configurePlugins()
77+
install(Authentication) {
78+
basic(AUTH_REALM) {
79+
validate { credentials ->
80+
if (credentials.name == validUser && credentials.password == validPassword) {
81+
UserIdPrincipal(credentials.name)
82+
} else {
83+
null
9684
}
9785
}
9886
}
99-
routing {
100-
authenticate(AUTH_REALM) {
101-
registerMcpServer {
102-
createMcpServer { principal<UserIdPrincipal>()?.name }
103-
}
87+
}
88+
routing {
89+
authenticate(AUTH_REALM) {
90+
registerMcpServer {
91+
createMcpServer { principal<UserIdPrincipal>()?.name }
10492
}
10593
}
106-
}.startSuspend(wait = false)
107-
108-
val httpClient = HttpClient(ClientCIO)
109-
try {
110-
httpClient.get("http://$HOST:${server.actualPort()}").status shouldBe HttpStatusCode.Unauthorized
111-
} finally {
112-
httpClient.close()
113-
server.stopSuspend(500, 1000)
11494
}
95+
}.startSuspend(wait = false)
96+
97+
val httpClient = HttpClient(ClientCIO)
98+
try {
99+
httpClient.get("http://$HOST:${server.actualPort()}").status shouldBe HttpStatusCode.Unauthorized
100+
} finally {
101+
httpClient.close()
102+
server.stopSuspend(500, 1000)
115103
}
116104
}
117105

118106
@Test
119-
fun `authenticated mcp client can read resource scoped to principal`() {
120-
runBlocking(Dispatchers.IO) {
121-
val server = embeddedServer(ServerCIO, host = HOST, port = 0) {
122-
configurePlugins()
123-
install(Authentication) {
124-
basic(AUTH_REALM) {
125-
validate { credentials ->
126-
if (credentials.name == validUser && credentials.password == validPassword) {
127-
UserIdPrincipal(credentials.name)
128-
} else {
129-
null
130-
}
107+
fun `authenticated mcp client can read resource scoped to principal`(): Unit = runBlocking {
108+
val server = embeddedServer(ServerCIO, host = HOST, port = 0) {
109+
configurePlugins()
110+
install(Authentication) {
111+
basic(AUTH_REALM) {
112+
validate { credentials ->
113+
if (credentials.name == validUser && credentials.password == validPassword) {
114+
UserIdPrincipal(credentials.name)
115+
} else {
116+
null
131117
}
132118
}
133119
}
134-
routing {
135-
authenticate(AUTH_REALM) {
136-
registerMcpServer {
137-
// `this` is the ApplicationCall at connection time.
138-
// The lambda passed to createMcpServer captures this call;
139-
// principal<T>() is safe to call from resource handlers because
140-
// the call's authentication context remains valid for the session lifetime.
141-
createMcpServer { principal<UserIdPrincipal>()?.name }
142-
}
120+
}
121+
routing {
122+
authenticate(AUTH_REALM) {
123+
registerMcpServer {
124+
// `this` is the ApplicationCall at connection time.
125+
// The lambda passed to createMcpServer captures this call;
126+
// principal<T>() is safe to call from resource handlers because
127+
// the call's authentication context remains valid for the session lifetime.
128+
createMcpServer { principal<UserIdPrincipal>()?.name }
143129
}
144130
}
145-
}.startSuspend(wait = false)
131+
}
132+
}.startSuspend(wait = false)
146133

147-
val baseUrl = "http://$HOST:${server.actualPort()}"
148-
var mcpClient: Client? = null
149-
try {
150-
mcpClient = Client(Implementation(name = "test-client", version = "1.0.0"))
151-
mcpClient.connect(createClientTransport(baseUrl, validUser, validPassword))
134+
val baseUrl = "http://$HOST:${server.actualPort()}"
135+
var mcpClient: Client? = null
136+
try {
137+
mcpClient = Client(Implementation(name = "test-client", version = "1.0.0"))
138+
mcpClient.connect(createClientTransport(baseUrl, validUser, validPassword))
152139

153-
val result = mcpClient.readResource(
154-
ReadResourceRequest(ReadResourceRequestParams(uri = WHOAMI_URI)),
155-
)
140+
val result = mcpClient.readResource(
141+
ReadResourceRequest(ReadResourceRequestParams(uri = WHOAMI_URI)),
142+
)
156143

157-
result.contents shouldBe listOf(
158-
TextResourceContents(
159-
text = validUser,
160-
uri = WHOAMI_URI,
161-
mimeType = "text/plain",
162-
),
163-
)
164-
} finally {
165-
mcpClient?.close()
166-
server.stopSuspend(500, 1000)
167-
}
144+
result.contents shouldBe listOf(
145+
TextResourceContents(
146+
text = validUser,
147+
uri = WHOAMI_URI,
148+
mimeType = "text/plain",
149+
),
150+
)
151+
} finally {
152+
mcpClient?.close()
153+
server.stopSuspend(500, 1000)
168154
}
169155
}
170156

integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/SseAuthenticationTest.kt

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import io.ktor.client.HttpClient
44
import io.ktor.client.plugins.sse.SSE
55
import io.ktor.client.request.basicAuth
66
import io.ktor.server.application.ApplicationCall
7-
import io.ktor.server.auth.principal
87
import io.ktor.server.routing.Route
98
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
109
import io.modelcontextprotocol.kotlin.sdk.integration.AbstractAuthenticationTest
@@ -13,15 +12,6 @@ import io.modelcontextprotocol.kotlin.sdk.server.mcp
1312
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
1413
import io.ktor.client.engine.cio.CIO as ClientCIO
1514

16-
/**
17-
* Integration tests for MCP-over-SSE placed behind Ktor authentication middleware.
18-
*
19-
* Demonstrates the pattern for issue #236: [io.ktor.server.auth.principal] is
20-
* accessible inside the `mcp { }` factory block via
21-
* [io.ktor.server.sse.ServerSSESession.call]. The principal is captured once per
22-
* SSE connection and can be closed over in resource/tool handlers to scope
23-
* responses to the authenticated user.
24-
*/
2515
class SseAuthenticationTest : AbstractAuthenticationTest() {
2616

2717
override fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server) {

0 commit comments

Comments
 (0)