Skip to content

Commit 58dfa97

Browse files
committed
test: Add Ktor authentication tests for protecting MCP routes
- Introduced `SseAuthenticationTest`, demonstrating how to protect MCP endpoints with authentication.
1 parent 15559e6 commit 58dfa97

7 files changed

Lines changed: 299 additions & 7 deletions

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version
4949
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
5050
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
5151
ktor-serialization = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
52+
ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref = "ktor" }
5253
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
5354
ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" }
5455
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }

integration-test/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ kotlin {
2222
implementation(libs.ktor.server.content.negotiation)
2323
implementation(libs.ktor.serialization)
2424
implementation(libs.ktor.server.websockets)
25+
implementation(libs.ktor.server.auth)
2526
implementation(libs.ktor.server.test.host)
2627
implementation(libs.ktor.server.content.negotiation)
2728
implementation(libs.ktor.serialization)
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package io.modelcontextprotocol.kotlin.sdk.integration
2+
3+
import io.kotest.matchers.shouldBe
4+
import io.ktor.client.HttpClient
5+
import io.ktor.client.request.get
6+
import io.ktor.http.HttpStatusCode
7+
import io.ktor.serialization.kotlinx.json.json
8+
import io.ktor.server.application.Application
9+
import io.ktor.server.application.ApplicationCall
10+
import io.ktor.server.application.install
11+
import io.ktor.server.auth.Authentication
12+
import io.ktor.server.auth.UserIdPrincipal
13+
import io.ktor.server.auth.authenticate
14+
import io.ktor.server.auth.basic
15+
import io.ktor.server.auth.principal
16+
import io.ktor.server.engine.embeddedServer
17+
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
18+
import io.ktor.server.routing.Route
19+
import io.ktor.server.routing.routing
20+
import io.modelcontextprotocol.kotlin.sdk.client.Client
21+
import io.modelcontextprotocol.kotlin.sdk.server.Server
22+
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
23+
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
24+
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
25+
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
26+
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
27+
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
28+
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
29+
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
30+
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
31+
import io.modelcontextprotocol.kotlin.test.utils.actualPort
32+
import kotlinx.coroutines.Dispatchers
33+
import kotlinx.coroutines.runBlocking
34+
import java.util.UUID
35+
import kotlin.test.Test
36+
import io.ktor.client.engine.cio.CIO as ClientCIO
37+
import io.ktor.server.cio.CIO as ServerCIO
38+
import io.ktor.server.sse.SSE as ServerSSE
39+
40+
/**
41+
* 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.
46+
*/
47+
abstract class AbstractAuthenticationTest {
48+
49+
protected companion object {
50+
const val HOST = "127.0.0.1"
51+
const val AUTH_REALM = "mcp-auth"
52+
const val WHOAMI_URI = "whoami://me"
53+
}
54+
55+
protected val validUser: String = "user-${UUID.randomUUID().toString().take(8)}"
56+
protected val validPassword: String = UUID.randomUUID().toString()
57+
58+
/**
59+
* 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.
64+
*/
65+
protected open fun Application.configurePlugins() {
66+
install(ServerSSE)
67+
// ContentNegotiation is required by the StreamableHttp transport for JSON body handling.
68+
// Installing it for SSE tests as well is harmless.
69+
install(ContentNegotiation) { json(McpJson) }
70+
}
71+
72+
/**
73+
* Registers the MCP server on the given route.
74+
* Concrete implementations should use transport-specific extensions (e.g., [Route.mcp] for SSE).
75+
*/
76+
abstract fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server)
77+
78+
/**
79+
* Creates a client transport configured with the given credentials.
80+
*/
81+
abstract fun createClientTransport(baseUrl: String, user: String, pass: String): Transport
82+
83+
@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+
}
96+
}
97+
}
98+
}
99+
routing {
100+
authenticate(AUTH_REALM) {
101+
registerMcpServer {
102+
createMcpServer { principal<UserIdPrincipal>()?.name }
103+
}
104+
}
105+
}
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)
114+
}
115+
}
116+
}
117+
118+
@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+
}
131+
}
132+
}
133+
}
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+
}
143+
}
144+
}
145+
}.startSuspend(wait = false)
146+
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))
152+
153+
val result = mcpClient.readResource(
154+
ReadResourceRequest(ReadResourceRequestParams(uri = WHOAMI_URI)),
155+
)
156+
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+
}
168+
}
169+
}
170+
171+
protected fun createMcpServer(principalProvider: () -> String?): Server = Server(
172+
serverInfo = Implementation(name = "test-server", version = "1.0.0"),
173+
options = ServerOptions(
174+
capabilities = ServerCapabilities(
175+
resources = ServerCapabilities.Resources(),
176+
),
177+
),
178+
).apply {
179+
addResource(
180+
uri = WHOAMI_URI,
181+
name = "Current User",
182+
description = "Returns the name of the authenticated user",
183+
mimeType = "text/plain",
184+
) {
185+
ReadResourceResult(
186+
contents = listOf(
187+
TextResourceContents(
188+
text = principalProvider() ?: "anonymous",
189+
uri = WHOAMI_URI,
190+
mimeType = "text/plain",
191+
),
192+
),
193+
)
194+
}
195+
}
196+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package io.modelcontextprotocol.kotlin.sdk.integration.sse
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.plugins.sse.SSE
5+
import io.ktor.client.request.basicAuth
6+
import io.ktor.server.application.ApplicationCall
7+
import io.ktor.server.auth.principal
8+
import io.ktor.server.routing.Route
9+
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
10+
import io.modelcontextprotocol.kotlin.sdk.integration.AbstractAuthenticationTest
11+
import io.modelcontextprotocol.kotlin.sdk.server.Server
12+
import io.modelcontextprotocol.kotlin.sdk.server.mcp
13+
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
14+
import io.ktor.client.engine.cio.CIO as ClientCIO
15+
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+
*/
25+
class SseAuthenticationTest : AbstractAuthenticationTest() {
26+
27+
override fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server) {
28+
mcp {
29+
serverFactory(call)
30+
}
31+
}
32+
33+
override fun createClientTransport(baseUrl: String, user: String, pass: String): Transport = SseClientTransport(
34+
client = HttpClient(ClientCIO) { install(SSE) },
35+
urlString = baseUrl,
36+
requestBuilder = { basicAuth(user, pass) },
37+
)
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.plugins.sse.SSE
5+
import io.ktor.client.request.basicAuth
6+
import io.ktor.server.application.ApplicationCall
7+
import io.ktor.server.routing.Route
8+
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
9+
import io.modelcontextprotocol.kotlin.sdk.integration.AbstractAuthenticationTest
10+
import io.modelcontextprotocol.kotlin.sdk.server.Server
11+
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
12+
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
13+
import io.ktor.client.engine.cio.CIO as ClientCIO
14+
15+
class StreamableHttpAuthenticationTest : AbstractAuthenticationTest() {
16+
17+
override fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server) {
18+
mcpStreamableHttp {
19+
serverFactory(call)
20+
}
21+
}
22+
23+
override fun createClientTransport(baseUrl: String, user: String, pass: String): Transport =
24+
StreamableHttpClientTransport(
25+
client = HttpClient(ClientCIO) { install(SSE) },
26+
url = baseUrl,
27+
requestBuilder = { basicAuth(user, pass) },
28+
)
29+
}

kotlin-sdk-server/api/kotlin-sdk-server.api

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,30 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt {
88
public static final fun mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V
99
public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
1010
public static final fun mcp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;)V
11-
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
12-
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
13-
public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
14-
public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
11+
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
12+
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
13+
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
14+
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
15+
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
16+
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
17+
public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
18+
public static final fun mcpStreamableHttp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
19+
public static final fun mcpStreamableHttp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
20+
public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
21+
public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
22+
public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
23+
}
24+
25+
public final class io/modelcontextprotocol/kotlin/sdk/server/McpStreamableHttpConfig {
26+
public fun <init> ()V
27+
public final fun getAllowedHosts ()Ljava/util/List;
28+
public final fun getAllowedOrigins ()Ljava/util/List;
29+
public final fun getEnableDnsRebindingProtection ()Z
30+
public final fun getEventStore ()Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;
31+
public final fun setAllowedHosts (Ljava/util/List;)V
32+
public final fun setAllowedOrigins (Ljava/util/List;)V
33+
public final fun setEnableDnsRebindingProtection (Z)V
34+
public final fun setEventStore (Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;)V
1535
}
1636

1737
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature {

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.modelcontextprotocol.kotlin.sdk.server
33
import io.kotest.assertions.ktor.client.shouldHaveContentType
44
import io.kotest.assertions.ktor.client.shouldHaveStatus
55
import io.ktor.client.HttpClient
6+
import io.ktor.client.request.HttpRequestBuilder
67
import io.ktor.client.request.post
78
import io.ktor.client.request.prepareGet
89
import io.ktor.client.request.setBody
@@ -29,9 +30,14 @@ abstract class AbstractKtorExtensionsTest {
2930
* - GET returns 200 with `text/event-stream` content type (SSE endpoint)
3031
* - POST with a valid MCP payload and session returns 202 Accepted
3132
* - POST without a sessionId returns 400 Bad Request
33+
*
34+
* Use [configureRequest] to add headers (e.g. `basicAuth(...)`) to every request.
3235
*/
33-
protected suspend fun HttpClient.assertMcpEndpointsAt(path: String) {
34-
prepareGet(path).execute { response ->
36+
protected suspend fun HttpClient.assertMcpEndpointsAt(
37+
path: String,
38+
configureRequest: HttpRequestBuilder.() -> Unit = {},
39+
) {
40+
prepareGet(path) { configureRequest() }.execute { response ->
3541
response.shouldHaveStatus(HttpStatusCode.OK)
3642
response.shouldHaveContentType(sseContentType)
3743

@@ -58,11 +64,12 @@ abstract class AbstractKtorExtensionsTest {
5864
val postResponse = post("$path?sessionId=$sessionId") {
5965
contentType(ContentType.Application.Json)
6066
setBody("""{"jsonrpc":"2.0","id":1,"method":"ping"}""")
67+
configureRequest()
6168
}
6269
postResponse.shouldHaveStatus(HttpStatusCode.Accepted)
6370
}
6471

6572
// POST without sessionId returns 400 Bad Request
66-
post(path).shouldHaveStatus(HttpStatusCode.BadRequest)
73+
post(path) { configureRequest() }.shouldHaveStatus(HttpStatusCode.BadRequest)
6774
}
6875
}

0 commit comments

Comments
 (0)