Skip to content
Draft
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
4 changes: 2 additions & 2 deletions buildSrc/src/main/kotlin/mcp.dokka.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ dokka {

documentedVisibilities(VisibilityModifier.Public)

externalDocumentationLinks.register("ktor-client") {
url("https://api.ktor.io/ktor-client/")
externalDocumentationLinks.register("ktor") {
url("https://api.ktor.io/")
packageListUrl("https://api.ktor.io/package-list")
}

Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref = "ktor" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
Expand Down
1 change: 1 addition & 0 deletions integration-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ kotlin {
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization)
implementation(libs.ktor.server.websockets)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package io.modelcontextprotocol.kotlin.sdk.integration

import io.kotest.matchers.shouldBe
import io.ktor.client.HttpClient
import io.ktor.client.request.basicAuth
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.UserIdPrincipal
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.basic
import io.ktor.server.auth.principal
import io.ktor.server.engine.embeddedServer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.Route
import io.ktor.server.routing.routing
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
import io.modelcontextprotocol.kotlin.test.utils.actualPort
import kotlinx.coroutines.runBlocking
import java.util.UUID
import kotlin.test.Test
import io.ktor.client.engine.cio.CIO as ClientCIO
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.sse.SSE as ServerSSE

/**
* Base class for MCP authentication integration tests.
*/
abstract class AbstractAuthenticationTest {

protected companion object {
const val HOST = "127.0.0.1"
const val AUTH_REALM = "mcp-auth"
const val WHOAMI_URI = "whoami://me"
}

protected val validUser: String = "user-${UUID.randomUUID().toString().take(8)}"
protected val validPassword: String = UUID.randomUUID().toString()
protected val invalidUser: String = "user-${UUID.randomUUID().toString().take(8)}"
protected val invalidPassword: String = UUID.randomUUID().toString()

/**
* Installs Ktor plugins required by the transport under test.
*/
protected open fun Application.configurePlugins() {
install(ServerSSE)
// ContentNegotiation is required by the StreamableHttp transport for JSON body handling.
// Installing it for SSE tests as well is harmless.
install(ContentNegotiation) { json(McpJson) }
}

/**
* Registers the MCP server on the given route.
*/
abstract fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server)

/**
* Creates a client transport configured with the given credentials.
*/
abstract fun createClientTransport(baseUrl: String, user: String, pass: String): Transport

@Test
fun `mcp behind basic auth rejects unauthenticated requests with 401`(): Unit = runBlocking {
val server = startAuthenticatedServer()

val httpClient = HttpClient(ClientCIO)
try {
httpClient.get("http://$HOST:${server.actualPort()}").status shouldBe HttpStatusCode.Unauthorized
} finally {
httpClient.close()
server.stopSuspend(500, 1000)
}
}

@Test
fun `mcp rejects requests with invalid credentials`(): Unit = runBlocking {
val server = startAuthenticatedServer()

val httpClient = HttpClient(ClientCIO) {
expectSuccess = false
}
try {
httpClient.get("http://$HOST:${server.actualPort()}") {
basicAuth(invalidUser, invalidPassword)
}.status shouldBe HttpStatusCode.Unauthorized
} finally {
httpClient.close()
server.stopSuspend(500, 1000)
}
}

@Test
fun `authenticated mcp client can read resource scoped to principal`(): Unit = runBlocking {
val server = startAuthenticatedServer()

val baseUrl = "http://$HOST:${server.actualPort()}"
var mcpClient: Client? = null
try {
mcpClient = Client(Implementation(name = "test-client", version = "1.0.0"))
mcpClient.connect(createClientTransport(baseUrl, validUser, validPassword))

val result = mcpClient.readResource(
ReadResourceRequest(ReadResourceRequestParams(uri = WHOAMI_URI)),
)

result.contents shouldBe listOf(
TextResourceContents(
text = validUser,
uri = WHOAMI_URI,
mimeType = "text/plain",
),
)
} finally {
mcpClient?.close()
server.stopSuspend(500, 1000)
}
}

private suspend fun startAuthenticatedServer() = embeddedServer(ServerCIO, host = HOST, port = 0) {
configurePlugins()
installBasicAuth()
routing {
authenticate(AUTH_REALM) {
registerMcpServer {
createMcpServer { principal<UserIdPrincipal>()?.name }
}
}
}
}.startSuspend(wait = false)

private fun Application.installBasicAuth() {
install(Authentication) {
basic(AUTH_REALM) {
validate { credentials ->
if (credentials.name == validUser && credentials.password == validPassword) {
UserIdPrincipal(credentials.name)
} else {
null
}
}
}
}
}

protected fun createMcpServer(principalProvider: () -> String?): Server = Server(
serverInfo = Implementation(name = "test-server", version = "1.0.0"),
options = ServerOptions(
capabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(),
),
),
).apply {
addResource(
uri = WHOAMI_URI,
name = "Current User",
description = "Returns the name of the authenticated user",
mimeType = "text/plain",
) {
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = principalProvider() ?: "anonymous",
uri = WHOAMI_URI,
mimeType = "text/plain",
),
),
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.modelcontextprotocol.kotlin.sdk.integration.sse

import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.SSE
import io.ktor.client.request.basicAuth
import io.ktor.server.application.ApplicationCall
import io.ktor.server.routing.Route
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
import io.modelcontextprotocol.kotlin.sdk.integration.AbstractAuthenticationTest
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.mcp
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.ktor.client.engine.cio.CIO as ClientCIO

class SseAuthenticationTest : AbstractAuthenticationTest() {

override fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server) {
mcp {
serverFactory(call)
}
}

override fun createClientTransport(baseUrl: String, user: String, pass: String): Transport = SseClientTransport(
client = HttpClient(ClientCIO) { install(SSE) },
urlString = baseUrl,
requestBuilder = { basicAuth(user, pass) },
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp

import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.SSE
import io.ktor.client.request.basicAuth
import io.ktor.server.application.ApplicationCall
import io.ktor.server.routing.Route
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.modelcontextprotocol.kotlin.sdk.integration.AbstractAuthenticationTest
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.ktor.client.engine.cio.CIO as ClientCIO

class StreamableHttpAuthenticationTest : AbstractAuthenticationTest() {

override fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server) {
mcpStreamableHttp {
serverFactory(call)
}
}

override fun createClientTransport(baseUrl: String, user: String, pass: String): Transport =
StreamableHttpClientTransport(
client = HttpClient(ClientCIO) { install(SSE) },
url = baseUrl,
requestBuilder = { basicAuth(user, pass) },
)
}
28 changes: 24 additions & 4 deletions kotlin-sdk-server/api/kotlin-sdk-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,30 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt {
public static final fun mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;)V
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
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
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
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
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
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
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
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
public static final fun mcpStreamableHttp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
public static final fun mcpStreamableHttp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
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
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
public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
}

public final class io/modelcontextprotocol/kotlin/sdk/server/McpStreamableHttpConfig {
public fun <init> ()V
public final fun getAllowedHosts ()Ljava/util/List;
public final fun getAllowedOrigins ()Ljava/util/List;
public final fun getEnableDnsRebindingProtection ()Z
public final fun getEventStore ()Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;
public final fun setAllowedHosts (Ljava/util/List;)V
public final fun setAllowedOrigins (Ljava/util/List;)V
public final fun setEnableDnsRebindingProtection (Z)V
public final fun setEventStore (Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;)V
}

public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature {
Expand Down
2 changes: 0 additions & 2 deletions kotlin-sdk-server/detekt-baseline-commonMainSourceSet.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@
<ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$*</ID>
<ID>MaxLineLength:ServerSession.kt:ServerSession$"Client requested unsupported protocol version $requestedVersion, falling back to $LATEST_PROTOCOL_VERSION"</ID>
<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>
<ID>ReturnCount:KtorServer.kt:private suspend fun existingStreamableTransport: StreamableHttpServerTransport?</ID>
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertCapabilityForMethod</ID>
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertNotificationCapability</ID>
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertRequestHandlerCapability</ID>
<ID>TooGenericExceptionCaught:SSEServerTransport.kt:SseServerTransport$e: Exception</ID>
<ID>TooGenericExceptionCaught:Server.kt:Server$e: Exception</ID>
<ID>TooGenericExceptionCaught:StdioServerTransport.kt:StdioServerTransport$e: Throwable</ID>
<ID>TooManyFunctions:KtorServer.kt:io.modelcontextprotocol.kotlin.sdk.server.KtorServer.kt</ID>
<ID>TooManyFunctions:Server.kt:Server</ID>
<ID>TooManyFunctions:ServerSession.kt:ServerSession : Protocol</ID>
</CurrentIssues>
Expand Down
3 changes: 0 additions & 3 deletions kotlin-sdk-server/detekt-baseline-main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>InjectDispatcher:FeatureNotificationService.kt:FeatureNotificationService$Default</ID>
<ID>LongParameterList:KtorServer.kt:private suspend fun RoutingContext.streamableTransport: StreamableHttpServerTransport?</ID>
<ID>LongParameterList:Server.kt:Server$public fun addTool</ID>
<ID>MagicNumber:StdioServerTransport.kt:StdioServerTransport$8192</ID>
<ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$"SSEServerTransport already started! If using Server class, note that connect() calls start() automatically."</ID>
Expand All @@ -12,14 +11,12 @@
<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>
<ID>NoNameShadowing:FeatureNotificationService.kt:FeatureNotificationService${ it.remove(job) }</ID>
<ID>RedundantSuspendModifier:ServerSession.kt:ServerSession$suspend</ID>
<ID>ReturnCount:KtorServer.kt:private suspend fun existingStreamableTransport: StreamableHttpServerTransport?</ID>
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertCapabilityForMethod</ID>
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertNotificationCapability</ID>
<ID>ThrowsCount:ServerSession.kt:ServerSession$override fun assertRequestHandlerCapability</ID>
<ID>TooGenericExceptionCaught:SSEServerTransport.kt:SseServerTransport$e: Exception</ID>
<ID>TooGenericExceptionCaught:Server.kt:Server$e: Exception</ID>
<ID>TooGenericExceptionCaught:StdioServerTransport.kt:StdioServerTransport$e: Throwable</ID>
<ID>TooManyFunctions:KtorServer.kt:io.modelcontextprotocol.kotlin.sdk.server.KtorServer.kt</ID>
<ID>TooManyFunctions:Server.kt:Server</ID>
<ID>TooManyFunctions:ServerSession.kt:ServerSession : Protocol</ID>
<ID>UnsafeCallOnNullableType:StreamableHttpServerTransport.kt:StreamableHttpServerTransport$responseRequestId!!</ID>
Expand Down
Loading
Loading