Ktor extensions for streamable http (#504)
## Motivation and Context Adds Ktor extensions for streaming data over the server-side HTTP transport ## How Has This Been Tested? Integration test ## Breaking Changes - ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed
This commit is contained in:
parent
c40fa16563
commit
da971aaee5
2 changed files with 356 additions and 16 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue