Move features(tools, prompts, resources) management to the FeatureRegistry (#365)

Move features(tools, prompts, resources) management to the
`FeatureRegistry` to unload `Server`
As for 'feature' naming, I refer to
https://modelcontextprotocol.io/docs/learn/server-concepts
This commit is contained in:
Maria Tigina 2025-11-03 18:27:03 +01:00 • committed by GitHub
commit 913c0d111e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 245 additions and 177 deletions

View file

@ -5,33 +5,35 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt {
public static final fun mcp (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function1;)V
}
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt {
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature {
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/Prompt;Lkotlin/jvm/functions/Function2;)V
public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/Prompt;
public final fun component2 ()Lkotlin/jvm/functions/Function2;
public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/Prompt;Lkotlin/jvm/functions/Function2;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt;
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt;Lio/modelcontextprotocol/kotlin/sdk/Prompt;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt;
public fun equals (Ljava/lang/Object;)Z
public fun getKey ()Ljava/lang/String;
public final fun getMessageProvider ()Lkotlin/jvm/functions/Function2;
public final fun getPrompt ()Lio/modelcontextprotocol/kotlin/sdk/Prompt;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredResource {
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredResource : io/modelcontextprotocol/kotlin/sdk/server/Feature {
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/Resource;Lkotlin/jvm/functions/Function2;)V
public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/Resource;
public final fun component2 ()Lkotlin/jvm/functions/Function2;
public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/Resource;Lkotlin/jvm/functions/Function2;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource;
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource;Lio/modelcontextprotocol/kotlin/sdk/Resource;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource;
public fun equals (Ljava/lang/Object;)Z
public fun getKey ()Ljava/lang/String;
public final fun getReadHandler ()Lkotlin/jvm/functions/Function2;
public final fun getResource ()Lio/modelcontextprotocol/kotlin/sdk/Resource;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredTool {
public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredTool : io/modelcontextprotocol/kotlin/sdk/server/Feature {
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/Tool;Lkotlin/jvm/functions/Function2;)V
public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/Tool;
public final fun component2 ()Lkotlin/jvm/functions/Function2;
@ -39,6 +41,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredTool {
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredTool;Lio/modelcontextprotocol/kotlin/sdk/Tool;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredTool;
public fun equals (Ljava/lang/Object;)Z
public final fun getHandler ()Lkotlin/jvm/functions/Function2;
public fun getKey ()Ljava/lang/String;
public final fun getTool ()Lio/modelcontextprotocol/kotlin/sdk/Tool;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;

View file

@ -0,0 +1,120 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.getAndUpdate
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.minus
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentSet
/**
* A generic registry for managing features of a specified type. This class provides thread-safe
* operations for adding, removing, and retrieving features from the registry.
*
* @param T The type of the feature, constrained to implement the [Feature] interface.
* @param featureType A string description of the type of feature being managed.
* Used primarily for logging purposes.
*/
internal class FeatureRegistry<T : Feature>(private val featureType: String) {
private val logger = KotlinLogging.logger(name = "FeatureRegistry[$featureType]")
/**
* Atomic variable used to maintain a thread-safe registry of features.
* Stores a persistent map where each feature is identified by a unique key.
*/
private val registry = atomic(persistentMapOf<FeatureKey, T>())
/**
* Provides a snapshot of all features currently registered in the registry.
* Keys represent unique identifiers for each feature, and values represent the associated features.
*/
internal val values: Map<FeatureKey, T>
get() = registry.value
/**
* Adds the specified feature to the registry.
*
* @param feature The feature to be added to the registry.
*/
internal fun add(feature: T) {
logger.info { "Adding $featureType: \"${feature.key}\"" }
registry.update { current -> current.put(feature.key, feature) }
logger.info { "Added $featureType: \"${feature.key}\"" }
}
/**
* Adds the given list of features to the registry. Each feature is mapped by its key
* and added to the current registry.
*
* @param features The list of features to add to the registry.
*/
internal fun addAll(features: List<T>) {
logger.info { "Adding ${featureType}s: ${features.size}" }
registry.update { current -> current.putAll(features.associateBy { it.key }) }
logger.info { "Added ${featureType}s: ${features.size}" }
}
/**
* Removes the feature associated with the given key from the registry.
*
* @param key The key of the feature to be removed.
* @return `true` if the feature was successfully removed, or `false` if the feature was not found.
*/
internal fun remove(key: FeatureKey): Boolean {
logger.info { "Removing $featureType: \"$key\"" }
val oldMap = registry.getAndUpdate { current -> current.remove(key) }
val removed = key in oldMap
logger.info {
if (removed) {
"Removed $featureType: \"$key\""
} else {
"$featureType not found: \"$key\""
}
}
return key in oldMap
}
/**
* Removes the features associated with the given keys from the registry.
*
* @param keys The list of keys whose associated features are to be removed.
* @return The number of features that were successfully removed.
*/
internal fun removeAll(keys: List<FeatureKey>): Int {
logger.info { "Removing ${featureType}s: ${keys.size}" }
val oldMap = registry.getAndUpdate { current -> current - keys.toPersistentSet() }
val removedCount = keys.count { it in oldMap }
logger.info {
if (removedCount > 0) {
"Removed ${featureType}s: $removedCount"
} else {
"No $featureType were removed"
}
}
return removedCount
}
/**
* Retrieves the feature associated with the given key from the registry.
*
* @param key The key of the feature to retrieve.
* @return The feature associated with the given key, or `null` if no such feature exists in the registry.
*/
internal fun get(key: FeatureKey): T? {
logger.info { "Getting $featureType: \"$key\"" }
val feature = registry.value[key]
logger.info {
if (feature != null) {
"Got $featureType: \"$key\""
} else {
"$featureType not found: \"$key\""
}
}
return feature
}
}

View file

@ -0,0 +1,56 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.modelcontextprotocol.kotlin.sdk.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.Prompt
import io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.Resource
import io.modelcontextprotocol.kotlin.sdk.Tool
internal typealias FeatureKey = String
/**
* Represents a feature with an associated unique key.
*/
internal interface Feature {
val key: FeatureKey
}
/**
* A wrapper class representing a registered tool on the server.
*
* @property tool The tool definition.
* @property handler A suspend function to handle the tool call requests.
*/
public data class RegisteredTool(val tool: Tool, val handler: suspend (CallToolRequest) -> CallToolResult) : Feature {
override val key: String = tool.name
}
/**
* A wrapper class representing a registered prompt on the server.
*
* @property prompt The prompt definition.
* @property messageProvider A suspend function that returns the prompt content when requested by the client.
*/
public data class RegisteredPrompt(
val prompt: Prompt,
val messageProvider: suspend (GetPromptRequest) -> GetPromptResult,
) : Feature {
override val key: String = prompt.name
}
/**
* A wrapper class representing a registered resource on the server.
*
* @property resource The resource definition.
* @property readHandler A suspend function to handle read requests for this resource.
*/
public data class RegisteredResource(
val resource: Resource,
val readHandler: suspend (ReadResourceRequest) -> ReadResourceResult,
) : Feature {
override val key: String = resource.uri
}

View file

@ -27,12 +27,8 @@ import io.modelcontextprotocol.kotlin.sdk.ToolAnnotations
import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.getAndUpdate
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.minus
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CancellationException
private val logger = KotlinLogging.logger {}
@ -59,7 +55,7 @@ public class ServerOptions(public val capabilities: ServerCapabilities, enforceS
* this server. The provider is called each time a new session is started to support dynamic instructions.
* @param block A block to configure the mcp server.
*/
@Suppress("TooManyFunctions")
public open class Server(
protected val serverInfo: Implementation,
protected val options: ServerOptions,
@ -92,15 +88,16 @@ public open class Server(
@Suppress("ktlint:standard:backing-property-naming")
private var _onClose: () -> Unit = {}
private val _tools = atomic(persistentMapOf<String, RegisteredTool>())
private val _prompts = atomic(persistentMapOf<String, RegisteredPrompt>())
private val _resources = atomic(persistentMapOf<String, RegisteredResource>())
private val toolRegistry = FeatureRegistry<RegisteredTool>("Tool")
private val promptRegistry = FeatureRegistry<RegisteredPrompt>("Prompt")
private val resourceRegistry = FeatureRegistry<RegisteredResource>("Resource")
public val tools: Map<String, RegisteredTool>
get() = _tools.value
get() = toolRegistry.values
public val prompts: Map<String, RegisteredPrompt>
get() = _prompts.value
get() = promptRegistry.values
public val resources: Map<String, RegisteredResource>
get() = _resources.value
get() = resourceRegistry.values
init {
block(this)
@ -108,7 +105,7 @@ public open class Server(
public suspend fun close() {
logger.debug { "Closing MCP server" }
sessions.value.forEach { it.close() }
sessions.value.forEach { session -> session.close() }
_onClose()
}
@ -172,12 +169,12 @@ public open class Server(
// Register cleanup handler to remove session from list when it closes
session.onClose {
logger.debug { "Removing closed session from active sessions list" }
sessions.update { list -> list - session }
sessions.update { list -> list.remove(session) }
}
logger.debug { "Server session connecting to transport" }
session.connect(transport)
logger.debug { "Server session successfully connected to transport" }
sessions.update { it.add(session) }
sessions.update { sessions -> sessions.add(session) }
_onConnect()
return session
@ -229,12 +226,12 @@ public open class Server(
* @throws IllegalStateException If the server does not support tools.
*/
public fun addTool(tool: Tool, handler: suspend (CallToolRequest) -> CallToolResult) {
if (options.capabilities.tools == null) {
check(options.capabilities.tools != null) {
logger.error { "Failed to add tool '${tool.name}': Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability. Enable it in ServerOptions.")
"Server does not support tools capability. Enable it in ServerOptions."
}
logger.info { "Registering tool: ${tool.name}" }
_tools.update { current -> current.put(tool.name, RegisteredTool(tool, handler)) }
toolRegistry.add(RegisteredTool(tool, handler))
}
/**
@ -249,6 +246,7 @@ public open class Server(
* @param handler A suspend function that handles executing the tool when called by the client.
* @throws IllegalStateException If the server does not support tools.
*/
@Suppress("LongParameterList")
public fun addTool(
name: String,
description: String,
@ -269,12 +267,11 @@ public open class Server(
* @throws IllegalStateException If the server does not support tools.
*/
public fun addTools(toolsToAdd: List<RegisteredTool>) {
if (options.capabilities.tools == null) {
check(options.capabilities.tools != null) {
logger.error { "Failed to add tools: Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability.")
"Server does not support tools capability."
}
logger.info { "Registering ${toolsToAdd.size} tools" }
_tools.update { current -> current.putAll(toolsToAdd.associateBy { it.tool.name }) }
toolRegistry.addAll(toolsToAdd)
}
/**
@ -285,23 +282,11 @@ public open class Server(
* @throws IllegalStateException If the server does not support tools.
*/
public fun removeTool(name: String): Boolean {
if (options.capabilities.tools == null) {
check(options.capabilities.tools != null) {
logger.error { "Failed to remove tool '$name': Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability.")
"Server does not support tools capability."
}
logger.info { "Removing tool: $name" }
val oldMap = _tools.getAndUpdate { current -> current.remove(name) }
val removed = name in oldMap
logger.debug {
if (removed) {
"Tool removed: $name"
} else {
"Tool not found: $name"
}
}
return removed
return toolRegistry.remove(name)
}
/**
@ -312,22 +297,12 @@ public open class Server(
* @throws IllegalStateException If the server does not support tools.
*/
public fun removeTools(toolNames: List<String>): Int {
if (options.capabilities.tools == null) {
check(options.capabilities.tools != null) {
logger.error { "Failed to remove tools: Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability.")
"Server does not support tools capability."
}
logger.info { "Removing ${toolNames.size} tools" }
val oldMap = _tools.getAndUpdate { current -> current - toolNames.toPersistentSet() }
val removedCount = toolNames.count { it in oldMap }
logger.info {
if (removedCount > 0) {
"Removed $removedCount tools"
} else {
"No tools were removed"
}
}
val removedCount = toolRegistry.removeAll(toolNames)
return removedCount
}
@ -339,12 +314,11 @@ public open class Server(
* @throws IllegalStateException If the server does not support prompts.
*/
public fun addPrompt(prompt: Prompt, promptProvider: suspend (GetPromptRequest) -> GetPromptResult) {
if (options.capabilities.prompts == null) {
check(options.capabilities.prompts != null) {
logger.error { "Failed to add prompt '${prompt.name}': Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
"Server does not support prompts capability."
}
logger.info { "Registering prompt: ${prompt.name}" }
_prompts.update { current -> current.put(prompt.name, RegisteredPrompt(prompt, promptProvider)) }
promptRegistry.add(RegisteredPrompt(prompt, promptProvider))
}
/**
@ -373,12 +347,11 @@ public open class Server(
* @throws IllegalStateException If the server does not support prompts.
*/
public fun addPrompts(promptsToAdd: List<RegisteredPrompt>) {
if (options.capabilities.prompts == null) {
check(options.capabilities.prompts != null) {
logger.error { "Failed to add prompts: Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
"Server does not support prompts capability."
}
logger.info { "Registering ${promptsToAdd.size} prompts" }
_prompts.update { current -> current.putAll(promptsToAdd.associateBy { it.prompt.name }) }
promptRegistry.addAll(promptsToAdd)
}
/**
@ -389,23 +362,12 @@ public open class Server(
* @throws IllegalStateException If the server does not support prompts.
*/
public fun removePrompt(name: String): Boolean {
if (options.capabilities.prompts == null) {
check(options.capabilities.prompts != null) {
logger.error { "Failed to remove prompt '$name': Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
"Server does not support prompts capability."
}
logger.info { "Removing prompt: $name" }
val oldMap = _prompts.getAndUpdate { current -> current.remove(name) }
val removed = name in oldMap
logger.debug {
if (removed) {
"Prompt removed: $name"
} else {
"Prompt not found: $name"
}
}
return removed
return promptRegistry.remove(name)
}
/**
@ -416,24 +378,12 @@ public open class Server(
* @throws IllegalStateException If the server does not support prompts.
*/
public fun removePrompts(promptNames: List<String>): Int {
if (options.capabilities.prompts == null) {
check(options.capabilities.prompts != null) {
logger.error { "Failed to remove prompts: Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
"Server does not support prompts capability."
}
logger.info { "Removing ${promptNames.size} prompts" }
val oldMap = _prompts.getAndUpdate { current -> current - promptNames.toPersistentSet() }
val removedCount = promptNames.count { it in oldMap }
logger.info {
if (removedCount > 0) {
"Removed $removedCount prompts"
} else {
"No prompts were removed"
}
}
return removedCount
return promptRegistry.removeAll(promptNames)
}
/**
@ -453,17 +403,12 @@ public open class Server(
mimeType: String = "text/html",
readHandler: suspend (ReadResourceRequest) -> ReadResourceResult,
) {
if (options.capabilities.resources == null) {
check(options.capabilities.resources != null) {
logger.error { "Failed to add resource '$name': Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
}
logger.info { "Registering resource: $name ($uri)" }
_resources.update { current ->
current.put(
uri,
RegisteredResource(Resource(uri, name, description, mimeType), readHandler),
)
"Server does not support resources capability."
}
val resource = Resource(uri, name, description, mimeType)
resourceRegistry.add(RegisteredResource(resource, readHandler))
}
/**
@ -473,12 +418,11 @@ public open class Server(
* @throws IllegalStateException If the server does not support resources.
*/
public fun addResources(resourcesToAdd: List<RegisteredResource>) {
if (options.capabilities.resources == null) {
check(options.capabilities.resources != null) {
logger.error { "Failed to add resources: Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
"Server does not support resources capability."
}
logger.info { "Registering ${resourcesToAdd.size} resources" }
_resources.update { current -> current.putAll(resourcesToAdd.associateBy { it.resource.uri }) }
resourceRegistry.addAll(resourcesToAdd)
}
/**
@ -489,23 +433,11 @@ public open class Server(
* @throws IllegalStateException If the server does not support resources.
*/
public fun removeResource(uri: String): Boolean {
if (options.capabilities.resources == null) {
check(options.capabilities.resources != null) {
logger.error { "Failed to remove resource '$uri': Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
"Server does not support resources capability."
}
logger.info { "Removing resource: $uri" }
val oldMap = _resources.getAndUpdate { current -> current.remove(uri) }
val removed = uri in oldMap
logger.debug {
if (removed) {
"Resource removed: $uri"
} else {
"Resource not found: $uri"
}
}
return removed
return resourceRegistry.remove(uri)
}
/**
@ -516,24 +448,11 @@ public open class Server(
* @throws IllegalStateException If the server does not support resources.
*/
public fun removeResources(uris: List<String>): Int {
if (options.capabilities.resources == null) {
check(options.capabilities.resources != null) {
logger.error { "Failed to remove resources: Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
"Server does not support resources capability."
}
logger.info { "Removing ${uris.size} resources" }
val oldMap = _resources.getAndUpdate { current -> current - uris.toPersistentSet() }
val removedCount = uris.count { it in oldMap }
logger.info {
if (removedCount > 0) {
"Removed $removedCount resources"
} else {
"No resources were removed"
}
}
return removedCount
return resourceRegistry.removeAll(uris)
}
// --- Internal Handlers ---
@ -545,17 +464,17 @@ public open class Server(
private suspend fun handleCallTool(request: CallToolRequest): CallToolResult {
logger.debug { "Handling tool call request for tool: ${request.name}" }
// Check if tool exists
val tool = _tools.value[request.name]
?: run {
logger.error { "Tool not found: ${request.name}" }
return CallToolResult(
content = listOf(TextContent(text = "Tool ${request.name} not found")),
isError = true,
)
}
// Check if the tool exists
val tool = toolRegistry.get(request.name) ?: run {
logger.error { "Tool not found: ${request.name}" }
return CallToolResult(
content = listOf(TextContent(text = "Tool ${request.name} not found")),
isError = true,
)
}
// Execute tool handler and catch any errors
@Suppress("TooGenericExceptionCaught")
// Execute the tool handler and catch any errors
return try {
logger.trace { "Executing tool ${request.name} with input: ${request.arguments}" }
tool.handler(request)
@ -577,7 +496,7 @@ public open class Server(
private suspend fun handleGetPrompt(request: GetPromptRequest): GetPromptResult {
logger.debug { "Handling get prompt request for: ${request.name}" }
val prompt = prompts[request.name]
val prompt = promptRegistry.get(request.name)
?: run {
logger.error { "Prompt not found: ${request.name}" }
throw IllegalArgumentException("Prompt not found: ${request.name}")
@ -592,7 +511,7 @@ public open class Server(
private suspend fun handleReadResource(request: ReadResourceRequest): ReadResourceResult {
logger.debug { "Handling read resource request for: ${request.uri}" }
val resource = resources[request.uri]
val resource = resourceRegistry.get(request.uri)
?: run {
logger.error { "Resource not found: ${request.uri}" }
throw IllegalArgumentException("Resource not found: ${request.uri}")
@ -605,33 +524,3 @@ public open class Server(
return ListResourceTemplatesResult(listOf())
}
}
/**
* A wrapper class representing a registered tool on the server.
*
* @property tool The tool definition.
* @property handler A suspend function to handle the tool call requests.
*/
public data class RegisteredTool(val tool: Tool, val handler: suspend (CallToolRequest) -> CallToolResult)
/**
* A wrapper class representing a registered prompt on the server.
*
* @property prompt The prompt definition.
* @property messageProvider A suspend function that returns the prompt content when requested by the client.
*/
public data class RegisteredPrompt(
val prompt: Prompt,
val messageProvider: suspend (GetPromptRequest) -> GetPromptResult,
)
/**
* A wrapper class representing a registered resource on the server.
*
* @property resource The resource definition.
* @property readHandler A suspend function to handle read requests for this resource.
*/
public data class RegisteredResource(
val resource: Resource,
val readHandler: suspend (ReadResourceRequest) -> ReadResourceResult,
)

View file

@ -12,7 +12,7 @@ import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class SSeIntegrationTest : AbstractSseIntegrationTest() {
class SseIntegrationTest : AbstractSseIntegrationTest() {
@Test
fun `client should be able to connect to sse server`() = runTest(timeout = 5.seconds) {