Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc8cc76012 | ||
|
|
58575ba5a6 | ||
|
|
6969176e6c |
10 changed files with 482 additions and 22 deletions
|
|
@ -14,4 +14,4 @@ kotlin.mpp.enableCInteropCommonization=true
|
||||||
kotlin.native.ignoreDisabledTargets=true
|
kotlin.native.ignoreDisabledTargets=true
|
||||||
|
|
||||||
group=io.modelcontextprotocol
|
group=io.modelcontextprotocol
|
||||||
version=0.8.3
|
version=0.8.4
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ kotlin {
|
||||||
implementation(libs.kotlinx.coroutines.test)
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.server.cio)
|
implementation(libs.ktor.server.cio)
|
||||||
|
implementation(libs.ktor.server.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization)
|
||||||
implementation(libs.ktor.server.websockets)
|
implementation(libs.ktor.server.websockets)
|
||||||
implementation(libs.ktor.server.test.host)
|
implementation(libs.ktor.server.test.host)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp
|
||||||
|
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.cio.CIOApplicationEngine
|
||||||
|
import io.ktor.server.engine.EmbeddedServer
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.Role
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||||
|
import io.ktor.server.cio.CIO as ServerCIO
|
||||||
|
|
||||||
|
open class AbstractStreamableHttpIntegrationTest {
|
||||||
|
|
||||||
|
suspend fun initTestServer(name: String? = null): StreamableHttpTestServer {
|
||||||
|
val mcpServer = Server(
|
||||||
|
Implementation(name = name ?: DEFAULT_SERVER_NAME, version = VERSION),
|
||||||
|
ServerOptions(
|
||||||
|
capabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
addPrompt(
|
||||||
|
name = "prompt",
|
||||||
|
description = "Prompt description",
|
||||||
|
arguments = listOf(
|
||||||
|
PromptArgument(
|
||||||
|
name = "client",
|
||||||
|
description = "Client name who requested a prompt",
|
||||||
|
required = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) { request ->
|
||||||
|
GetPromptResult(
|
||||||
|
messages = listOf(
|
||||||
|
PromptMessage(
|
||||||
|
role = Role.User,
|
||||||
|
content = TextContent("Prompt for client ${request.params.arguments?.get("client")}"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
description = "Prompt for ${request.params.name}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ktorServer = embeddedServer(
|
||||||
|
ServerCIO,
|
||||||
|
host = URL,
|
||||||
|
port = PORT,
|
||||||
|
) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(McpJson)
|
||||||
|
}
|
||||||
|
mcpStreamableHttp { mcpServer }
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamableHttpTestServer(
|
||||||
|
mcpServer = mcpServer,
|
||||||
|
ktorServer = ktorServer.startSuspend(wait = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StreamableHttpTestServer(
|
||||||
|
val mcpServer: Server,
|
||||||
|
val ktorServer: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal const val DEFAULT_SERVER_NAME = "streamable-http-test-server"
|
||||||
|
internal const val VERSION = "1.0.0"
|
||||||
|
internal const val URL = "127.0.0.1"
|
||||||
|
internal const val PORT = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.plugins.sse.SSE
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.actualPort
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import io.ktor.client.engine.cio.CIO as ClientCIO
|
||||||
|
|
||||||
|
class StreamableHttpIntegrationTest : AbstractStreamableHttpIntegrationTest() {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `client should receive responses via POST json and server notifications via GET sse`() =
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
var server: StreamableHttpTestServer? = null
|
||||||
|
var client: Client? = null
|
||||||
|
var httpClient: HttpClient? = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
server = initTestServer()
|
||||||
|
val port = server.ktorServer.actualPort()
|
||||||
|
|
||||||
|
httpClient = HttpClient(ClientCIO) { install(SSE) }
|
||||||
|
val transport = StreamableHttpClientTransport(
|
||||||
|
client = httpClient,
|
||||||
|
url = "http://$URL:$port/mcp",
|
||||||
|
).apply {
|
||||||
|
protocolVersion = LATEST_PROTOCOL_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
client = Client(Implementation("streamable-http-client", VERSION))
|
||||||
|
client.connect(transport)
|
||||||
|
|
||||||
|
val prompt = getPrompt(client, "Client A")
|
||||||
|
assertTrue { "Client A" in prompt }
|
||||||
|
} finally {
|
||||||
|
client?.close()
|
||||||
|
httpClient?.close()
|
||||||
|
server?.ktorServer?.stopSuspend(1000, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getPrompt(client: Client, clientName: String): String {
|
||||||
|
val response = client.getPrompt(
|
||||||
|
GetPromptRequest(
|
||||||
|
GetPromptRequestParams(
|
||||||
|
name = "prompt",
|
||||||
|
arguments = mapOf("client" to clientName),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (response.messages.first().content as? TextContent)?.text
|
||||||
|
?: error("Failed to receive prompt for Client $clientName")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4492,13 +4492,15 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ToolListChangedNotif
|
||||||
public final class io/modelcontextprotocol/kotlin/sdk/types/ToolSchema {
|
public final class io/modelcontextprotocol/kotlin/sdk/types/ToolSchema {
|
||||||
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema$Companion;
|
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema$Companion;
|
||||||
public fun <init> ()V
|
public fun <init> ()V
|
||||||
public fun <init> (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)V
|
public fun <init> (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)V
|
||||||
public synthetic fun <init> (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
|
public synthetic fun <init> (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
|
||||||
public final fun component1 ()Lkotlinx/serialization/json/JsonObject;
|
public final fun component1 ()Lkotlinx/serialization/json/JsonObject;
|
||||||
public final fun component2 ()Ljava/util/List;
|
public final fun component2 ()Ljava/util/List;
|
||||||
public final fun copy (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;
|
public final fun component3 ()Lkotlinx/serialization/json/JsonObject;
|
||||||
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;
|
public final fun copy (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;
|
||||||
|
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;
|
||||||
public fun equals (Ljava/lang/Object;)Z
|
public fun equals (Ljava/lang/Object;)Z
|
||||||
|
public final fun getDefs ()Lkotlinx/serialization/json/JsonObject;
|
||||||
public final fun getProperties ()Lkotlinx/serialization/json/JsonObject;
|
public final fun getProperties ()Lkotlinx/serialization/json/JsonObject;
|
||||||
public final fun getRequired ()Ljava/util/List;
|
public final fun getRequired ()Ljava/util/List;
|
||||||
public final fun getType ()Ljava/lang/String;
|
public final fun getType ()Ljava/lang/String;
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,15 @@ public data class Tool(
|
||||||
* @property type Always "object" for tool schemas.
|
* @property type Always "object" for tool schemas.
|
||||||
* @property properties Optional map of property names to their schema definitions.
|
* @property properties Optional map of property names to their schema definitions.
|
||||||
* @property required Optional list of property names that are required.
|
* @property required Optional list of property names that are required.
|
||||||
|
* @property defs Optional schema definitions available to references in [properties]. Serialized as `$defs`.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
public data class ToolSchema(val properties: JsonObject? = null, val required: List<String>? = null) {
|
public data class ToolSchema(
|
||||||
|
val properties: JsonObject? = null,
|
||||||
|
val required: List<String>? = null,
|
||||||
|
@SerialName("\$defs")
|
||||||
|
val defs: JsonObject? = null,
|
||||||
|
) {
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
@EncodeDefault
|
@EncodeDefault
|
||||||
val type: String = "object"
|
val type: String = "object"
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,57 @@ class ToolsTest {
|
||||||
assertEquals("language", tool.meta?.get("category")?.jsonPrimitive?.content)
|
assertEquals("language", tool.meta?.get("category")?.jsonPrimitive?.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should serialize ToolSchema with defs`() {
|
||||||
|
val tool = toolWithDefs()
|
||||||
|
|
||||||
|
val json = McpJson.encodeToString(tool)
|
||||||
|
|
||||||
|
json shouldEqualJson toolWithDefsJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should deserialize ToolSchema with defs`() {
|
||||||
|
val json = $$"""
|
||||||
|
{
|
||||||
|
"name": "create-page",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"$defs": {
|
||||||
|
"parentRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"page_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"parent": {
|
||||||
|
"$ref": "#/$defs/parentRequest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["parent"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val tool = McpJson.decodeFromString<Tool>(json)
|
||||||
|
|
||||||
|
val schema = tool.inputSchema
|
||||||
|
val defs = schema.defs
|
||||||
|
assertNotNull(defs)
|
||||||
|
val parentRequest = defs["parentRequest"]?.jsonObject
|
||||||
|
assertNotNull(parentRequest)
|
||||||
|
assertEquals("object", parentRequest["type"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals(
|
||||||
|
$$"#/$defs/parentRequest",
|
||||||
|
schema.properties?.get("parent")?.jsonObject?.get($$"$ref")?.jsonPrimitive?.content,
|
||||||
|
)
|
||||||
|
assertEquals(listOf("parent"), schema.required)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `should serialize CallToolRequest with arguments`() {
|
fun `should serialize CallToolRequest with arguments`() {
|
||||||
val request = CallToolRequest(
|
val request = CallToolRequest(
|
||||||
|
|
@ -520,4 +571,65 @@ class ToolsTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
private fun toolWithDefs(): Tool = Tool(
|
||||||
|
name = "create-page",
|
||||||
|
inputSchema = toolSchemaWithDefs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun toolSchemaWithDefs(): ToolSchema = ToolSchema(
|
||||||
|
properties = buildJsonObject {
|
||||||
|
put(
|
||||||
|
"parent",
|
||||||
|
buildJsonObject {
|
||||||
|
put($$"$ref", $$"#/$defs/parentRequest")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
required = listOf("parent"),
|
||||||
|
defs = buildJsonObject {
|
||||||
|
put(
|
||||||
|
"parentRequest",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "object")
|
||||||
|
put(
|
||||||
|
"properties",
|
||||||
|
buildJsonObject {
|
||||||
|
put(
|
||||||
|
"page_id",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "string")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun toolWithDefsJson(): String = $$"""
|
||||||
|
{
|
||||||
|
"name": "create-page",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"$defs": {
|
||||||
|
"parentRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"page_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"parent": {
|
||||||
|
"$ref": "#/$defs/parentRequest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["parent"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ 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/application/Application;Lkotlin/jvm/functions/Function1;)V
|
||||||
public static final fun mcp (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
|
public static final fun mcp (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
|
||||||
public static final fun mcp (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function1;)V
|
public static final fun mcp (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function1;)V
|
||||||
|
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;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;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;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;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature {
|
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,15 @@ package io.modelcontextprotocol.kotlin.sdk.server
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.application.ApplicationCall
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.request.ApplicationRequest
|
||||||
|
import io.ktor.server.request.header
|
||||||
import io.ktor.server.response.respond
|
import io.ktor.server.response.respond
|
||||||
import io.ktor.server.routing.Routing
|
import io.ktor.server.routing.Routing
|
||||||
import io.ktor.server.routing.RoutingContext
|
import io.ktor.server.routing.RoutingContext
|
||||||
|
import io.ktor.server.routing.delete
|
||||||
|
import io.ktor.server.routing.get
|
||||||
import io.ktor.server.routing.post
|
import io.ktor.server.routing.post
|
||||||
import io.ktor.server.routing.route
|
import io.ktor.server.routing.route
|
||||||
import io.ktor.server.routing.routing
|
import io.ktor.server.routing.routing
|
||||||
|
|
@ -14,6 +19,8 @@ import io.ktor.server.sse.SSE
|
||||||
import io.ktor.server.sse.ServerSSESession
|
import io.ktor.server.sse.ServerSSESession
|
||||||
import io.ktor.server.sse.sse
|
import io.ktor.server.sse.sse
|
||||||
import io.ktor.utils.io.KtorDsl
|
import io.ktor.utils.io.KtorDsl
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
|
||||||
import kotlinx.atomicfu.AtomicRef
|
import kotlinx.atomicfu.AtomicRef
|
||||||
import kotlinx.atomicfu.atomic
|
import kotlinx.atomicfu.atomic
|
||||||
import kotlinx.atomicfu.update
|
import kotlinx.atomicfu.update
|
||||||
|
|
@ -23,13 +30,15 @@ import kotlinx.coroutines.awaitCancellation
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
internal class SseTransportManager(transports: Map<String, SseServerTransport> = emptyMap()) {
|
internal class TransportManager(transports: Map<String, AbstractTransport> = emptyMap()) {
|
||||||
private val transports: AtomicRef<PersistentMap<String, SseServerTransport>> = atomic(transports.toPersistentMap())
|
private val transports: AtomicRef<PersistentMap<String, AbstractTransport>> = atomic(transports.toPersistentMap())
|
||||||
|
|
||||||
fun getTransport(sessionId: String): SseServerTransport? = transports.value[sessionId]
|
fun hasTransport(sessionId: String): Boolean = transports.value.containsKey(sessionId)
|
||||||
|
|
||||||
fun addTransport(transport: SseServerTransport) {
|
fun getTransport(sessionId: String): AbstractTransport? = transports.value[sessionId]
|
||||||
transports.update { it.put(transport.sessionId, transport) }
|
|
||||||
|
fun addTransport(sessionId: String, transport: AbstractTransport) {
|
||||||
|
transports.update { it.put(sessionId, transport) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeTransport(sessionId: String) {
|
fun removeTransport(sessionId: String) {
|
||||||
|
|
@ -49,14 +58,14 @@ public fun Routing.mcp(path: String, block: ServerSSESession.() -> Server) {
|
||||||
*/
|
*/
|
||||||
@KtorDsl
|
@KtorDsl
|
||||||
public fun Routing.mcp(block: ServerSSESession.() -> Server) {
|
public fun Routing.mcp(block: ServerSSESession.() -> Server) {
|
||||||
val sseTransportManager = SseTransportManager()
|
val transportManager = TransportManager()
|
||||||
|
|
||||||
sse {
|
sse {
|
||||||
mcpSseEndpoint("", sseTransportManager, block)
|
mcpSseEndpoint("", transportManager, block)
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
mcpPostEndpoint(sseTransportManager)
|
mcpPostEndpoint(transportManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,18 +78,98 @@ public fun Application.mcp(block: ServerSSESession.() -> Server) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@KtorDsl
|
||||||
|
public fun Application.mcpStreamableHttp(
|
||||||
|
enableDnsRebindingProtection: Boolean = false,
|
||||||
|
allowedHosts: List<String>? = null,
|
||||||
|
allowedOrigins: List<String>? = null,
|
||||||
|
eventStore: EventStore? = null,
|
||||||
|
block: RoutingContext.() -> Server,
|
||||||
|
) {
|
||||||
|
install(SSE)
|
||||||
|
|
||||||
|
val transportManager = TransportManager()
|
||||||
|
|
||||||
|
routing {
|
||||||
|
route("/mcp") {
|
||||||
|
sse {
|
||||||
|
val transport = existingStreamableTransport(call, transportManager) ?: return@sse
|
||||||
|
transport.handleRequest(this, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
val transport = streamableTransport(
|
||||||
|
transportManager = transportManager,
|
||||||
|
enableDnsRebindingProtection = enableDnsRebindingProtection,
|
||||||
|
allowedHosts = allowedHosts,
|
||||||
|
allowedOrigins = allowedOrigins,
|
||||||
|
eventStore = eventStore,
|
||||||
|
block = block,
|
||||||
|
)
|
||||||
|
?: return@post
|
||||||
|
|
||||||
|
transport.handleRequest(null, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
val transport = existingStreamableTransport(call, transportManager) ?: return@delete
|
||||||
|
transport.handleRequest(null, call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@KtorDsl
|
||||||
|
public fun Application.mcpStatelessStreamableHttp(
|
||||||
|
enableDnsRebindingProtection: Boolean = false,
|
||||||
|
allowedHosts: List<String>? = null,
|
||||||
|
allowedOrigins: List<String>? = null,
|
||||||
|
eventStore: EventStore? = null,
|
||||||
|
block: RoutingContext.() -> Server,
|
||||||
|
) {
|
||||||
|
install(SSE)
|
||||||
|
|
||||||
|
routing {
|
||||||
|
route("/mcp") {
|
||||||
|
post {
|
||||||
|
mcpStatelessStreamableHttpEndpoint(
|
||||||
|
enableDnsRebindingProtection = enableDnsRebindingProtection,
|
||||||
|
allowedHosts = allowedHosts,
|
||||||
|
allowedOrigins = allowedOrigins,
|
||||||
|
eventStore = eventStore,
|
||||||
|
block = block,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
get {
|
||||||
|
call.reject(
|
||||||
|
HttpStatusCode.MethodNotAllowed,
|
||||||
|
RPCError.ErrorCode.CONNECTION_CLOSED,
|
||||||
|
"Method not allowed.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delete {
|
||||||
|
call.reject(
|
||||||
|
HttpStatusCode.MethodNotAllowed,
|
||||||
|
RPCError.ErrorCode.CONNECTION_CLOSED,
|
||||||
|
"Method not allowed.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun ServerSSESession.mcpSseEndpoint(
|
internal suspend fun ServerSSESession.mcpSseEndpoint(
|
||||||
postEndpoint: String,
|
postEndpoint: String,
|
||||||
sseTransportManager: SseTransportManager,
|
transportManager: TransportManager,
|
||||||
block: ServerSSESession.() -> Server,
|
block: ServerSSESession.() -> Server,
|
||||||
) {
|
) {
|
||||||
val transport = mcpSseTransport(postEndpoint, sseTransportManager)
|
val transport = mcpSseTransport(postEndpoint, transportManager)
|
||||||
|
|
||||||
val server = block()
|
val server = block()
|
||||||
|
|
||||||
server.onClose {
|
server.onClose {
|
||||||
logger.info { "Server connection closed for sessionId: ${transport.sessionId}" }
|
logger.info { "Server connection closed for sessionId: ${transport.sessionId}" }
|
||||||
sseTransportManager.removeTransport(transport.sessionId)
|
transportManager.removeTransport(transport.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
server.createSession(transport)
|
server.createSession(transport)
|
||||||
|
|
@ -92,16 +181,41 @@ internal suspend fun ServerSSESession.mcpSseEndpoint(
|
||||||
|
|
||||||
internal fun ServerSSESession.mcpSseTransport(
|
internal fun ServerSSESession.mcpSseTransport(
|
||||||
postEndpoint: String,
|
postEndpoint: String,
|
||||||
sseTransportManager: SseTransportManager,
|
transportManager: TransportManager,
|
||||||
): SseServerTransport {
|
): SseServerTransport {
|
||||||
val transport = SseServerTransport(postEndpoint, this)
|
val transport = SseServerTransport(postEndpoint, this)
|
||||||
sseTransportManager.addTransport(transport)
|
transportManager.addTransport(transport.sessionId, transport)
|
||||||
logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" }
|
logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" }
|
||||||
|
|
||||||
return transport
|
return transport
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun RoutingContext.mcpPostEndpoint(sseTransportManager: SseTransportManager) {
|
internal suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint(
|
||||||
|
enableDnsRebindingProtection: Boolean = false,
|
||||||
|
allowedHosts: List<String>? = null,
|
||||||
|
allowedOrigins: List<String>? = null,
|
||||||
|
eventStore: EventStore? = null,
|
||||||
|
block: RoutingContext.() -> Server,
|
||||||
|
) {
|
||||||
|
val transport = StreamableHttpServerTransport(
|
||||||
|
enableDnsRebindingProtection = enableDnsRebindingProtection,
|
||||||
|
allowedHosts = allowedHosts,
|
||||||
|
allowedOrigins = allowedOrigins,
|
||||||
|
eventStore = eventStore,
|
||||||
|
enableJsonResponse = true,
|
||||||
|
).also { it.setSessionIdGenerator(null) }
|
||||||
|
|
||||||
|
logger.info { "New stateless StreamableHttp connection established without sessionId" }
|
||||||
|
|
||||||
|
val server = block()
|
||||||
|
server.onClose { logger.info { "Server connection closed without sessionId" } }
|
||||||
|
server.createSession(transport)
|
||||||
|
|
||||||
|
transport.handleRequest(null, this.call)
|
||||||
|
logger.debug { "Server connected to transport without sessionId" }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) {
|
||||||
val sessionId: String = call.request.queryParameters["sessionId"] ?: run {
|
val sessionId: String = call.request.queryParameters["sessionId"] ?: run {
|
||||||
call.respond(HttpStatusCode.BadRequest, "sessionId query parameter is not provided")
|
call.respond(HttpStatusCode.BadRequest, "sessionId query parameter is not provided")
|
||||||
return
|
return
|
||||||
|
|
@ -109,7 +223,7 @@ internal suspend fun RoutingContext.mcpPostEndpoint(sseTransportManager: SseTran
|
||||||
|
|
||||||
logger.debug { "Received message for sessionId: $sessionId" }
|
logger.debug { "Received message for sessionId: $sessionId" }
|
||||||
|
|
||||||
val transport = sseTransportManager.getTransport(sessionId)
|
val transport = transportManager.getTransport(sessionId) as SseServerTransport?
|
||||||
if (transport == null) {
|
if (transport == null) {
|
||||||
logger.warn { "Session not found for sessionId: $sessionId" }
|
logger.warn { "Session not found for sessionId: $sessionId" }
|
||||||
call.respond(HttpStatusCode.NotFound, "Session not found")
|
call.respond(HttpStatusCode.NotFound, "Session not found")
|
||||||
|
|
@ -119,3 +233,74 @@ internal suspend fun RoutingContext.mcpPostEndpoint(sseTransportManager: SseTran
|
||||||
transport.handlePostMessage(call)
|
transport.handlePostMessage(call)
|
||||||
logger.trace { "Message handled for sessionId: $sessionId" }
|
logger.trace { "Message handled for sessionId: $sessionId" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ApplicationRequest.sessionId(): String? = header(MCP_SESSION_ID_HEADER)
|
||||||
|
|
||||||
|
private suspend fun existingStreamableTransport(
|
||||||
|
call: ApplicationCall,
|
||||||
|
transportManager: TransportManager,
|
||||||
|
): StreamableHttpServerTransport? {
|
||||||
|
val sessionId = call.request.sessionId()
|
||||||
|
if (sessionId.isNullOrEmpty()) {
|
||||||
|
call.reject(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
RPCError.ErrorCode.CONNECTION_CLOSED,
|
||||||
|
"Bad Request: No valid session ID provided",
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val transport = transportManager.getTransport(sessionId) as? StreamableHttpServerTransport
|
||||||
|
if (transport == null) {
|
||||||
|
call.reject(
|
||||||
|
HttpStatusCode.NotFound,
|
||||||
|
RPCError.ErrorCode.CONNECTION_CLOSED,
|
||||||
|
"Session not found",
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun RoutingContext.streamableTransport(
|
||||||
|
transportManager: TransportManager,
|
||||||
|
enableDnsRebindingProtection: Boolean,
|
||||||
|
allowedHosts: List<String>?,
|
||||||
|
allowedOrigins: List<String>?,
|
||||||
|
eventStore: EventStore?,
|
||||||
|
block: RoutingContext.() -> Server,
|
||||||
|
): StreamableHttpServerTransport? {
|
||||||
|
val sessionId = call.request.sessionId()
|
||||||
|
if (sessionId != null) {
|
||||||
|
val transport = transportManager.getTransport(sessionId) as? StreamableHttpServerTransport
|
||||||
|
return transport ?: existingStreamableTransport(call, transportManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
val transport = StreamableHttpServerTransport(
|
||||||
|
enableDnsRebindingProtection = enableDnsRebindingProtection,
|
||||||
|
allowedHosts = allowedHosts,
|
||||||
|
allowedOrigins = allowedOrigins,
|
||||||
|
eventStore = eventStore,
|
||||||
|
enableJsonResponse = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.setOnSessionInitialized { initializedSessionId ->
|
||||||
|
transportManager.addTransport(initializedSessionId, transport)
|
||||||
|
logger.info { "New StreamableHttp connection established and stored with sessionId: $initializedSessionId" }
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.setOnSessionClosed { closedSession ->
|
||||||
|
transportManager.removeTransport(closedSession)
|
||||||
|
logger.info { "Closed StreamableHttp connection and removed sessionId: $closedSession" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val server = block()
|
||||||
|
server.onClose {
|
||||||
|
transport.sessionId?.let { transportManager.removeTransport(it) }
|
||||||
|
logger.info { "Server connection closed for sessionId: ${transport.sessionId}" }
|
||||||
|
}
|
||||||
|
server.createSession(transport)
|
||||||
|
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,9 @@ public class StreamableHttpServerTransport(
|
||||||
*/
|
*/
|
||||||
public suspend fun handlePostRequest(session: ServerSSESession?, call: ApplicationCall) {
|
public suspend fun handlePostRequest(session: ServerSSESession?, call: ApplicationCall) {
|
||||||
try {
|
try {
|
||||||
if (!enableJsonResponse && session == null) error("Server session can't be null with json response")
|
if (!enableJsonResponse && session == null) {
|
||||||
|
error("Server session can't be null for SSE responses")
|
||||||
|
}
|
||||||
|
|
||||||
val acceptHeader = call.request.header(HttpHeaders.Accept)
|
val acceptHeader = call.request.header(HttpHeaders.Accept)
|
||||||
val isAcceptEventStream = acceptHeader.accepts(ContentType.Text.EventStream)
|
val isAcceptEventStream = acceptHeader.accepts(ContentType.Text.EventStream)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue