Server feature list changed and resource updated notifications (#441)
<!-- Provide a brief summary of your changes --> ## Motivation and Context <!-- Why is this change needed? What problem does it solve? --> Implemented thread-safe notification service following eventual consistency principle. Fixes https://github.com/modelcontextprotocol/kotlin-sdk/issues/249 Contracts: * If the client connection and feature update happened in the same thread, the timestamps of the events will be consequent ``` server.connect(client) server.add(feature) // client will receive the notification ``` ``` server.add(feature) server.connect(client) // client will NOT receive the notification ``` * If the client connection and feature update happened in the different threads, both options can be possible The flow of the notification is explained in the diagram: <img width="663" height="413" alt="image" src="https://github.com/user-attachments/assets/e2c4c108-53dd-408d-97ef-97110ce5c979" /> Documentation references: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#list-changed-notification https://modelcontextprotocol.io/specification/2025-06-18/server/tools#list-changed-notification https://modelcontextprotocol.io/specification/2025-06-18/server/resources#list-changed-notification https://modelcontextprotocol.io/specification/2025-06-18/server/resources#subscriptions Questions: * I haven't found the subscription confirmed response message content docs, how it should look like? ## How Has This Been Tested? <!-- Have you tested this in a real application? Which scenarios were tested? --> Multiple tests with various notification scenarios were added ## Breaking Changes <!-- Will users need to update their code or configurations? --> ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] 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 <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [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 ## Additional context <!-- Add any other context, implementation notes, or design decisions -->
This commit is contained in:
parent
cc9b50ff76
commit
4645ebfd17
13 changed files with 1307 additions and 60 deletions
|
|
@ -82,6 +82,8 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server {
|
|||
public final fun onConnect (Lkotlin/jvm/functions/Function0;)V
|
||||
public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V
|
||||
public final fun ping (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public final fun removeNotificationHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V
|
||||
public final fun removeNotificationHandler (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V
|
||||
public final fun removePrompt (Ljava/lang/String;)Z
|
||||
public final fun removePrompts (Ljava/util/List;)I
|
||||
public final fun removeResource (Ljava/lang/String;)Z
|
||||
|
|
@ -93,6 +95,8 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server {
|
|||
public final fun sendResourceListChanged (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public final fun sendResourceUpdated (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ResourceUpdatedNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public final fun sendToolListChanged (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public final fun setNotificationHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;Lkotlin/jvm/functions/Function1;)V
|
||||
public final fun setNotificationHandler (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/Method;Lkotlin/jvm/functions/Function1;)V
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Prompt
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Resource
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.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
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Notification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.atomicfu.getAndUpdate
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
/**
|
||||
* Represents an event for the notification service.
|
||||
*
|
||||
* @property timestamp A timestamp for the event.
|
||||
*/
|
||||
private sealed class NotificationEvent(open val timestamp: Long)
|
||||
|
||||
/**
|
||||
* Represents an event for a notification.
|
||||
*
|
||||
* @property notification The notification associated with the event.
|
||||
*/
|
||||
private class SendEvent(override val timestamp: Long, val notification: Notification) : NotificationEvent(timestamp)
|
||||
|
||||
/** Represents an event marking the end of notification processing. */
|
||||
private class EndEvent(override val timestamp: Long) : NotificationEvent(timestamp)
|
||||
|
||||
/**
|
||||
* Represents a job that handles session-specific notifications, processing events
|
||||
* and delivering relevant notifications to the associated session.
|
||||
*
|
||||
* This class listens to a stream of notification events and processes them
|
||||
* based on the event type and the resource subscriptions associated with the session.
|
||||
* It allows subscribing to or unsubscribing from specific resource keys for granular
|
||||
* notification handling. The job can also be canceled to stop processing further events.
|
||||
* Notification with timestamps older than the starting timestamp are skipped.
|
||||
*/
|
||||
private class SessionNotificationJob {
|
||||
private val job: Job
|
||||
private val resourceSubscriptions = atomic(persistentMapOf<FeatureKey, Long>())
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
/**
|
||||
* Constructor for the SessionNotificationJob, responsible for processing notification events
|
||||
* and dispatching appropriate notifications to the provided server session. The job operates
|
||||
* within the given coroutine scope and begins handling events starting from the specified
|
||||
* timestamp.
|
||||
*
|
||||
* @param session The server session where notifications will be dispatched.
|
||||
* @param scope The coroutine scope in which this job operates.
|
||||
* @param events A shared flow of notification events that the job listens to.
|
||||
* @param fromTimestamp The timestamp from which the job starts processing events.
|
||||
*/
|
||||
constructor(
|
||||
session: ServerSession,
|
||||
scope: CoroutineScope,
|
||||
events: SharedFlow<NotificationEvent>,
|
||||
fromTimestamp: Long,
|
||||
) {
|
||||
logger.info { "Starting notification job from timestamp $fromTimestamp for sessionId: ${session.sessionId} " }
|
||||
job = scope.launch {
|
||||
events.takeWhile { it !is EndEvent }.collect { event ->
|
||||
when (event) {
|
||||
is SendEvent -> {
|
||||
handleSendNotificationEvent(event, session, fromTimestamp)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.warn { "Skipping event: $event" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles sending a notification event to a specific server session.
|
||||
*
|
||||
* @param event The notification event to be processed.
|
||||
* @param session The server session to which the notification should be sent.
|
||||
* @param fromTimestamp The timestamp to filter events.
|
||||
* Notifications with timestamps older than this value are skipped.
|
||||
*/
|
||||
private suspend fun handleSendNotificationEvent(event: SendEvent, session: ServerSession, fromTimestamp: Long) {
|
||||
if (event.timestamp < fromTimestamp) {
|
||||
logger.info {
|
||||
"Skipping event with id: ${event.timestamp} as it is older than startingEventId $fromTimestamp: $event"
|
||||
}
|
||||
return
|
||||
}
|
||||
when (val notification = event.notification) {
|
||||
is PromptListChangedNotification,
|
||||
is ResourceListChangedNotification,
|
||||
is ToolListChangedNotification,
|
||||
-> {
|
||||
logger.info { "Sending list changed notification for sessionId: ${session.sessionId}" }
|
||||
session.notification(notification)
|
||||
}
|
||||
|
||||
is ResourceUpdatedNotification -> {
|
||||
resourceSubscriptions.value[notification.params.uri]?.let { resourceFromTimestamp ->
|
||||
if (event.timestamp >= resourceFromTimestamp) {
|
||||
logger.info {
|
||||
"Sending resource updated notification for resource ${notification.params.uri} " +
|
||||
"to sessionId: ${session.sessionId}"
|
||||
}
|
||||
session.notification(notification)
|
||||
} else {
|
||||
logger.info {
|
||||
"Skipping resource updated notification for resource ${notification.params.uri} " +
|
||||
"as it is older than subscription timestamp $resourceFromTimestamp"
|
||||
}
|
||||
}
|
||||
} ?: {
|
||||
logger.info {
|
||||
"No subscription for resource ${notification.params.uri}. " +
|
||||
"Skipping notification: $notification"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.warn { "Skipping notification: $notification" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a resource identified by the given feature key.
|
||||
*
|
||||
* @param resourceKey The key representing the resource to subscribe to.
|
||||
* @param timestamp The timestamp of the subscription.
|
||||
*/
|
||||
fun subscribe(resourceKey: FeatureKey, timestamp: Long) {
|
||||
resourceSubscriptions.getAndUpdate { it.put(resourceKey, timestamp) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from a resource identified by the given feature key.
|
||||
*
|
||||
* @param resourceKey The key representing the resource to unsubscribe from.
|
||||
*/
|
||||
fun unsubscribe(resourceKey: FeatureKey) {
|
||||
resourceSubscriptions.getAndUpdate { it.remove(resourceKey) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the notification service to complete its operations.
|
||||
*/
|
||||
suspend fun join() {
|
||||
job.join()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the notification service job.
|
||||
*/
|
||||
fun cancel() {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service responsible for managing and emitting notifications related to feature changes.
|
||||
*
|
||||
* This service facilitates notification subscriptions for different sessions and supports managing
|
||||
* listeners for feature-related events. Notifications include changes in tool lists, prompt lists,
|
||||
* resource lists, and updates to specific resources.
|
||||
*
|
||||
* This class operates on a background coroutine scope to handle notifications asynchronously.
|
||||
* It maintains jobs associated with sessions and features for controlling active subscriptions.
|
||||
*
|
||||
* Key Responsibilities:
|
||||
* - Emit notifications for various feature-related events.
|
||||
* - Provide listeners for handling feature change events.
|
||||
* - Allow clients to subscribe or unsubscribe from specific notifications.
|
||||
*
|
||||
* Notifications managed:
|
||||
* - Tool list change notifications.
|
||||
* - Prompt list change notifications.
|
||||
* - Resource list change notifications.
|
||||
* - Resource updates pertaining to specific resources.
|
||||
*/
|
||||
internal class FeatureNotificationService(
|
||||
notificationBufferCapacity: Int = Channel.UNLIMITED,
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private val clock: Clock = Clock.System,
|
||||
) {
|
||||
/** Coroutine scope used to handle asynchronous notifications. */
|
||||
private val notificationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/** Shared flow used to emit events within the feature notification service. */
|
||||
private val notificationEvents = MutableSharedFlow<NotificationEvent>(
|
||||
extraBufferCapacity = notificationBufferCapacity,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND,
|
||||
)
|
||||
|
||||
/** Active emit jobs. */
|
||||
private val emitJobs = atomic(persistentSetOf<Job>())
|
||||
|
||||
/** Notification jobs associated with sessions. */
|
||||
private val sessionNotificationJobs = atomic(persistentMapOf<ServerSessionKey, SessionNotificationJob>())
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
private fun featureListener(notificationProvider: (FeatureKey) -> ServerNotification): FeatureListener =
|
||||
object : FeatureListener {
|
||||
override fun onFeatureUpdated(featureKey: FeatureKey) {
|
||||
val notification = notificationProvider(featureKey)
|
||||
logger.debug { "Emitting notification: ${notification.method.value}" }
|
||||
emit(notification)
|
||||
}
|
||||
}
|
||||
|
||||
/** Listener for tool feature events. */
|
||||
internal val toolListChangedListener: FeatureListener = featureListener { ToolListChangedNotification() }
|
||||
|
||||
/** Listener for prompt feature events. */
|
||||
internal val promptListChangedListener: FeatureListener = featureListener { PromptListChangedNotification() }
|
||||
|
||||
/** Listener for resource feature events. */
|
||||
internal val resourceListChangedListener: FeatureListener = featureListener { ResourceListChangedNotification() }
|
||||
|
||||
/** Listener for resource update events. */
|
||||
internal val resourceUpdatedListener: FeatureListener =
|
||||
featureListener { ResourceUpdatedNotification(ResourceUpdatedNotificationParams(uri = it)) }
|
||||
|
||||
/**
|
||||
* Subscribes session to list changed notifications for all features and resource update notifications.
|
||||
* For each session the job is created and stored until the [unsubscribeSession] method is called.
|
||||
* In case of session already subscribed to list changed notifications, the method will skip the subscription and
|
||||
* continue to send notification using the existing job.
|
||||
*
|
||||
* @param session The session to subscribe.
|
||||
*/
|
||||
internal fun subscribeSession(session: ServerSession) {
|
||||
logger.info { "Subscribing session for notifications sessionId: ${session.sessionId}" }
|
||||
|
||||
val timestamp = getCurrentTimestamp()
|
||||
|
||||
sessionNotificationJobs.getAndUpdate {
|
||||
if (it.containsKey(session.sessionId)) {
|
||||
logger.info { "Session already subscribed: ${session.sessionId}" }
|
||||
return@getAndUpdate it
|
||||
} else {
|
||||
it.put(
|
||||
session.sessionId,
|
||||
SessionNotificationJob(
|
||||
session = session,
|
||||
scope = notificationScope,
|
||||
events = notificationEvents,
|
||||
// Save the first event id to process, as notification can be emitted after the subscription
|
||||
fromTimestamp = timestamp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info { "Subscribed session for notifications sessionId: ${session.sessionId}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes a session from list changed notifications for all features.
|
||||
* Cancels and removes the job associated with the given session's notifications.
|
||||
*
|
||||
* @param session The session to unsubscribe from list changed notifications.
|
||||
*/
|
||||
internal fun unsubscribeSession(session: ServerSession) {
|
||||
logger.info { "Unsubscribing from list changed notifications for sessionId: ${session.sessionId}" }
|
||||
sessionNotificationJobs.getAndUpdate {
|
||||
it[session.sessionId]?.cancel()
|
||||
it.remove(session.sessionId)
|
||||
}
|
||||
logger.info { "Unsubscribed from list changed notifications for sessionId: ${session.sessionId}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a session to notifications for resource updates pertaining to the given resource key.
|
||||
*
|
||||
* @param session The session to subscribe.
|
||||
* @param resourceKey The resource key to subscribe to.
|
||||
*/
|
||||
internal fun subscribeToResourceUpdate(session: ServerSession, resourceKey: FeatureKey) {
|
||||
logger.info { "Subscribing to resource $resourceKey update notifications for sessionId: ${session.sessionId}" }
|
||||
// Set starting event id for resources notifications to skip events emitted before the subscription
|
||||
sessionNotificationJobs.value[session.sessionId]?.subscribe(resourceKey, getCurrentTimestamp())
|
||||
logger.info { "Subscribed to resource $resourceKey update notifications for sessionId: ${session.sessionId}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes a session from notifications for resource updates pertaining to the given resource key.
|
||||
*
|
||||
* @param session The session to unsubscribe from.
|
||||
* @param resourceKey The resource key to unsubscribe from.
|
||||
*/
|
||||
internal fun unsubscribeFromResourceUpdate(session: ServerSession, resourceKey: FeatureKey) {
|
||||
logger.info {
|
||||
"Unsubscribing from resource $resourceKey update notifications for sessionId: ${session.sessionId}"
|
||||
}
|
||||
sessionNotificationJobs.value[session.sessionId]?.unsubscribe(resourceKey)
|
||||
logger.info {
|
||||
"Unsubscribed from resource $resourceKey update notifications for sessionId: ${session.sessionId}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits a notification to all active sessions. */
|
||||
private fun emit(notification: Notification) {
|
||||
// Create a timestamp before emit to ensure notifications are processed in order
|
||||
val timestamp = getCurrentTimestamp()
|
||||
|
||||
logger.info { "Emitting notification $timestamp: $notification" }
|
||||
|
||||
// Launching emit lazily to put it to the jobs queue before the completion
|
||||
val job = notificationScope.launch(start = CoroutineStart.LAZY) {
|
||||
logger.debug { "Actually emitting notification $timestamp: $notification" }
|
||||
notificationEvents.emit(SendEvent(timestamp, notification))
|
||||
logger.debug { "Notification emitted $timestamp: $notification" }
|
||||
}
|
||||
|
||||
// Add job to set before starting
|
||||
emitJobs.getAndUpdate { it.add(job) }
|
||||
|
||||
// Register completion
|
||||
job.invokeOnCompletion {
|
||||
emitJobs.getAndUpdate { it.remove(job) }
|
||||
}
|
||||
|
||||
// Start the job after it's safely added
|
||||
job.start()
|
||||
}
|
||||
|
||||
/** Returns the current timestamp in milliseconds. */
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun getCurrentTimestamp(): Long = clock.now().toEpochMilliseconds()
|
||||
|
||||
suspend fun close() {
|
||||
logger.info { "Closing feature notification service" }
|
||||
|
||||
// Making sure all emitting jobs are completed
|
||||
emitJobs.value.joinAll()
|
||||
|
||||
// Emitting end event to complete all session notification jobs
|
||||
notificationScope.launch {
|
||||
logger.info { "Emitting end event" }
|
||||
notificationEvents.emit(EndEvent(getCurrentTimestamp()))
|
||||
logger.info { "End event emitted" }
|
||||
}.join()
|
||||
|
||||
// Making sure all session notification jobs are completed (after receiving the end event)
|
||||
sessionNotificationJobs.value.values.forEach { it.join() }
|
||||
// Cancelling notification scope to stop processing further events
|
||||
notificationScope.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,17 @@ 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
|
||||
|
||||
/**
|
||||
* A listener interface for receiving notifications about feature changes in registry.
|
||||
*/
|
||||
internal interface FeatureListener {
|
||||
fun onFeatureUpdated(featureKey: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
@ -33,6 +41,16 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
|
|||
internal val values: Map<FeatureKey, T>
|
||||
get() = registry.value
|
||||
|
||||
private val listeners = atomic(persistentListOf<FeatureListener>())
|
||||
|
||||
internal fun addListener(listener: FeatureListener) {
|
||||
listeners.update { it.add(listener) }
|
||||
}
|
||||
|
||||
internal fun removeListener(listener: FeatureListener) {
|
||||
listeners.update { it.remove(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the specified feature to the registry.
|
||||
*
|
||||
|
|
@ -40,8 +58,11 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
|
|||
*/
|
||||
internal fun add(feature: T) {
|
||||
logger.info { "Adding $featureType: \"${feature.key}\"" }
|
||||
registry.update { current -> current.put(feature.key, feature) }
|
||||
val oldMap = registry.getAndUpdate { current -> current.put(feature.key, feature) }
|
||||
val oldFeature = oldMap[feature.key]
|
||||
|
||||
logger.info { "Added $featureType: \"${feature.key}\"" }
|
||||
notifyFeatureUpdated(oldFeature, feature)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -52,8 +73,13 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
|
|||
*/
|
||||
internal fun addAll(features: List<T>) {
|
||||
logger.info { "Adding ${featureType}s: ${features.size}" }
|
||||
registry.update { current -> current.putAll(features.associateBy { it.key }) }
|
||||
val oldMap = registry.getAndUpdate { current -> current.putAll(features.associateBy { it.key }) }
|
||||
|
||||
logger.info { "Added ${featureType}s: ${features.size}" }
|
||||
for (feature in features) {
|
||||
val oldFeature = oldMap[feature.key]
|
||||
notifyFeatureUpdated(oldFeature, feature)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,15 +92,17 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
|
|||
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\""
|
||||
}
|
||||
val removedFeature = oldMap[key]
|
||||
val removed = removedFeature != null
|
||||
|
||||
if (removed) {
|
||||
logger.info { "Removed $featureType: \"$key\"" }
|
||||
notifyFeatureUpdated(removedFeature, null)
|
||||
} else {
|
||||
logger.info { "$featureType not found: \"$key\"" }
|
||||
}
|
||||
return key in oldMap
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -87,13 +115,16 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
|
|||
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"
|
||||
}
|
||||
val removedFeatures = keys.mapNotNull { oldMap[it] }
|
||||
val removedCount = removedFeatures.size
|
||||
|
||||
if (removedCount > 0) {
|
||||
logger.info { "Removed ${featureType}s: $removedCount" }
|
||||
} else {
|
||||
logger.info { "No $featureType were removed" }
|
||||
}
|
||||
removedFeatures.forEach {
|
||||
notifyFeatureUpdated(it, null)
|
||||
}
|
||||
|
||||
return removedCount
|
||||
|
|
@ -108,13 +139,22 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
|
|||
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\""
|
||||
}
|
||||
if (feature != null) {
|
||||
logger.info { "Got $featureType: \"$key\"" }
|
||||
} else {
|
||||
logger.info { "$featureType not found: \"$key\"" }
|
||||
}
|
||||
|
||||
return feature
|
||||
}
|
||||
|
||||
private fun notifyFeatureUpdated(oldFeature: T?, newFeature: T?) {
|
||||
val featureKey = (oldFeature?.key ?: newFeature?.key) ?: run {
|
||||
logger.error { "Notification should have feature key, but none found" }
|
||||
return
|
||||
}
|
||||
|
||||
logger.info { "Notifying listeners on feature update" }
|
||||
listeners.value.forEach { it.onFeatureUpdated(featureKey) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Notification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Prompt
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
|
||||
|
|
@ -33,12 +34,16 @@ import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.Resource
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Tool
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
|
|
@ -58,6 +63,11 @@ public class ServerOptions(public val capabilities: ServerCapabilities, enforceS
|
|||
* You can register tools, prompts, and resources using [addTool], [addPrompt], and [addResource].
|
||||
* The server will then automatically handle listing and retrieval requests from the client.
|
||||
*
|
||||
* In case the server supports feature list notification or resource substitution,
|
||||
* the server will automatically send notifications for all connected clients.
|
||||
* Currently, after subscription to a resource, the server will NOT send the subscription confirmation
|
||||
* as this response schema is not defined in the protocol.
|
||||
*
|
||||
* @param serverInfo Information about this server implementation (name, version).
|
||||
* @param options Configuration options for the server.
|
||||
* @param instructionsProvider Optional provider for instructions from the server to the client about how to use
|
||||
|
|
@ -86,14 +96,6 @@ public open class Server(
|
|||
block: Server.() -> Unit = {},
|
||||
) : this(serverInfo, options, { instructions }, block)
|
||||
|
||||
private val sessionRegistry = ServerSessionRegistry()
|
||||
|
||||
/**
|
||||
* Provides a snapshot of all sessions currently registered in the server
|
||||
*/
|
||||
public val sessions: Map<ServerSessionKey, ServerSession>
|
||||
get() = sessionRegistry.sessions
|
||||
|
||||
@Suppress("ktlint:standard:backing-property-naming")
|
||||
private var _onInitialized: (() -> Unit) = {}
|
||||
|
||||
|
|
@ -103,14 +105,51 @@ public open class Server(
|
|||
@Suppress("ktlint:standard:backing-property-naming")
|
||||
private var _onClose: () -> Unit = {}
|
||||
|
||||
private val toolRegistry = FeatureRegistry<RegisteredTool>("Tool")
|
||||
private val promptRegistry = FeatureRegistry<RegisteredPrompt>("Prompt")
|
||||
private val resourceRegistry = FeatureRegistry<RegisteredResource>("Resource")
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private val notificationService = FeatureNotificationService()
|
||||
|
||||
private val sessionRegistry = ServerSessionRegistry()
|
||||
|
||||
private val toolRegistry = FeatureRegistry<RegisteredTool>("Tool").apply {
|
||||
if (options.capabilities.tools?.listChanged ?: false) {
|
||||
addListener(notificationService.toolListChangedListener)
|
||||
}
|
||||
}
|
||||
private val promptRegistry = FeatureRegistry<RegisteredPrompt>("Prompt").apply {
|
||||
if (options.capabilities.prompts?.listChanged ?: false) {
|
||||
addListener(notificationService.promptListChangedListener)
|
||||
}
|
||||
}
|
||||
private val resourceRegistry = FeatureRegistry<RegisteredResource>("Resource").apply {
|
||||
if (options.capabilities.resources?.listChanged ?: false) {
|
||||
addListener(notificationService.resourceListChangedListener)
|
||||
}
|
||||
if (options.capabilities.resources?.subscribe ?: false) {
|
||||
addListener(notificationService.resourceUpdatedListener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a snapshot of all sessions currently registered in the server
|
||||
*/
|
||||
public val sessions: Map<ServerSessionKey, ServerSession>
|
||||
get() = sessionRegistry.sessions
|
||||
|
||||
/**
|
||||
* Provides a snapshot of all tools currently registered in the server
|
||||
*/
|
||||
public val tools: Map<String, RegisteredTool>
|
||||
get() = toolRegistry.values
|
||||
|
||||
/**
|
||||
* Provides a snapshot of all prompts currently registered in the server
|
||||
*/
|
||||
public val prompts: Map<String, RegisteredPrompt>
|
||||
get() = promptRegistry.values
|
||||
|
||||
/**
|
||||
* Provides a snapshot of all resources currently registered in the server
|
||||
*/
|
||||
public val resources: Map<String, RegisteredResource>
|
||||
get() = resourceRegistry.values
|
||||
|
||||
|
|
@ -120,6 +159,7 @@ public open class Server(
|
|||
|
||||
public suspend fun close() {
|
||||
logger.debug { "Closing MCP server" }
|
||||
notificationService.close()
|
||||
sessions.forEach { (sessionId, session) ->
|
||||
logger.info { "Closing session $sessionId" }
|
||||
session.close()
|
||||
|
|
@ -182,17 +222,32 @@ public open class Server(
|
|||
session.setRequestHandler<ListResourceTemplatesRequest>(Method.Defined.ResourcesTemplatesList) { _, _ ->
|
||||
handleListResourceTemplates()
|
||||
}
|
||||
if (options.capabilities.resources?.subscribe ?: false) {
|
||||
session.setRequestHandler<SubscribeRequest>(Method.Defined.ResourcesSubscribe) { request, _ ->
|
||||
handleSubscribeResources(session, request)
|
||||
// Does not return any confirmation as the structure is not stated in the protocol
|
||||
null
|
||||
}
|
||||
session.setRequestHandler<UnsubscribeRequest>(Method.Defined.ResourcesUnsubscribe) { request, _ ->
|
||||
handleUnsubscribeResources(session, request)
|
||||
// Does not return any confirmation as the structure is not stated in the protocol
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register cleanup handler to remove session from list when it closes
|
||||
session.onClose {
|
||||
logger.debug { "Removing closed session from active sessions list" }
|
||||
notificationService.unsubscribeSession(session)
|
||||
sessionRegistry.removeSession(session.sessionId)
|
||||
}
|
||||
|
||||
logger.debug { "Server session connecting to transport" }
|
||||
session.connect(transport)
|
||||
logger.debug { "Server session successfully connected to transport" }
|
||||
sessionRegistry.addSession(session)
|
||||
notificationService.subscribeSession(session)
|
||||
|
||||
_onConnect()
|
||||
return session
|
||||
|
|
@ -484,7 +539,25 @@ public open class Server(
|
|||
}
|
||||
|
||||
// --- Internal Handlers ---
|
||||
private suspend fun handleListTools(): ListToolsResult {
|
||||
private fun handleSubscribeResources(session: ServerSession, request: SubscribeRequest) {
|
||||
if (options.capabilities.resources?.subscribe ?: false) {
|
||||
logger.debug { "Subscribing to resources" }
|
||||
notificationService.subscribeToResourceUpdate(session, request.params.uri)
|
||||
} else {
|
||||
logger.debug { "Failed to subscribe to resources: Server does not support resources capability" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnsubscribeResources(session: ServerSession, request: UnsubscribeRequest) {
|
||||
if (options.capabilities.resources?.subscribe ?: false) {
|
||||
logger.debug { "Unsubscribing from resources" }
|
||||
notificationService.unsubscribeFromResourceUpdate(session, request.params.uri)
|
||||
} else {
|
||||
logger.debug { "Failed to unsubscribe from resources: Server does not support resources capability" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleListTools(): ListToolsResult {
|
||||
val toolList = tools.values.map { it.tool }
|
||||
return ListToolsResult(tools = toolList, nextCursor = null)
|
||||
}
|
||||
|
|
@ -518,7 +591,7 @@ public open class Server(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun handleListPrompts(): ListPromptsResult {
|
||||
private fun handleListPrompts(): ListPromptsResult {
|
||||
logger.debug { "Handling list prompts request" }
|
||||
return ListPromptsResult(prompts = prompts.values.map { it.prompt })
|
||||
}
|
||||
|
|
@ -534,7 +607,7 @@ public open class Server(
|
|||
return prompt.messageProvider(request)
|
||||
}
|
||||
|
||||
private suspend fun handleListResources(): ListResourcesResult {
|
||||
private fun handleListResources(): ListResourcesResult {
|
||||
logger.debug { "Handling list resources request" }
|
||||
return ListResourcesResult(resources = resources.values.map { it.resource })
|
||||
}
|
||||
|
|
@ -550,7 +623,7 @@ public open class Server(
|
|||
return resource.readHandler(request)
|
||||
}
|
||||
|
||||
private suspend fun handleListResourceTemplates(): ListResourceTemplatesResult {
|
||||
private fun handleListResourceTemplates(): ListResourceTemplatesResult {
|
||||
// If you have resource templates, return them here. For now, return empty.
|
||||
return ListResourceTemplatesResult(listOf())
|
||||
}
|
||||
|
|
@ -675,4 +748,30 @@ public open class Server(
|
|||
}
|
||||
}
|
||||
// End the ServerSession redirection section
|
||||
|
||||
// Start the notification handling section
|
||||
public fun <T : Notification> setNotificationHandler(method: Method, handler: (notification: T) -> Deferred<Unit>) {
|
||||
sessions.forEach { (_, session) ->
|
||||
session.setNotificationHandler(method, handler)
|
||||
}
|
||||
}
|
||||
|
||||
public fun removeNotificationHandler(method: Method) {
|
||||
sessions.forEach { (_, session) ->
|
||||
session.removeNotificationHandler(method)
|
||||
}
|
||||
}
|
||||
|
||||
public fun <T : Notification> setNotificationHandler(
|
||||
sessionId: String,
|
||||
method: Method,
|
||||
handler: (notification: T) -> Deferred<Unit>,
|
||||
) {
|
||||
sessionRegistry.getSessionOrNull(sessionId)?.setNotificationHandler(method, handler)
|
||||
}
|
||||
|
||||
public fun removeNotificationHandler(sessionId: String, method: Method) {
|
||||
sessionRegistry.getSessionOrNull(sessionId)?.removeNotificationHandler(method)
|
||||
}
|
||||
// End the notification handling section
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.BeforeTest
|
||||
|
|
@ -107,4 +113,94 @@ class InMemoryTransportTest {
|
|||
serverTransport.start()
|
||||
assertEquals(message, receivedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should send ToolListChangedNotification from server to client`() = runTest {
|
||||
val notification = ToolListChangedNotification(
|
||||
BaseNotificationParams(),
|
||||
)
|
||||
|
||||
var receivedMessage: JSONRPCMessage? = null
|
||||
clientTransport.onMessage { msg ->
|
||||
receivedMessage = msg
|
||||
}
|
||||
|
||||
val rpcMessage = notification.toJSON()
|
||||
serverTransport.send(rpcMessage)
|
||||
assertEquals(rpcMessage, receivedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should send PromptListChangedNotification from server to client`() = runTest {
|
||||
val notification = PromptListChangedNotification(
|
||||
BaseNotificationParams(),
|
||||
)
|
||||
|
||||
var receivedMessage: JSONRPCMessage? = null
|
||||
clientTransport.onMessage { msg ->
|
||||
receivedMessage = msg
|
||||
}
|
||||
|
||||
val rpcMessage = notification.toJSON()
|
||||
serverTransport.send(rpcMessage)
|
||||
assertEquals(rpcMessage, receivedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should send ResourceListChangedNotification from server to client`() = runTest {
|
||||
val notification = ResourceListChangedNotification(
|
||||
BaseNotificationParams(),
|
||||
)
|
||||
|
||||
var receivedMessage: JSONRPCMessage? = null
|
||||
clientTransport.onMessage { msg ->
|
||||
receivedMessage = msg
|
||||
}
|
||||
|
||||
val rpcMessage = notification.toJSON()
|
||||
serverTransport.send(rpcMessage)
|
||||
assertEquals(rpcMessage, receivedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should send ResourceUpdatedNotification from server to client`() = runTest {
|
||||
val notification = ResourceUpdatedNotification(
|
||||
ResourceUpdatedNotificationParams(
|
||||
uri = "file:///workspace/data.json",
|
||||
),
|
||||
)
|
||||
|
||||
var receivedMessage: JSONRPCMessage? = null
|
||||
clientTransport.onMessage { msg ->
|
||||
receivedMessage = msg
|
||||
}
|
||||
|
||||
val rpcMessage = notification.toJSON()
|
||||
serverTransport.send(rpcMessage)
|
||||
assertEquals(rpcMessage, receivedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle multiple notifications in sequence`() = runTest {
|
||||
val notifications = listOf(
|
||||
ToolListChangedNotification(),
|
||||
PromptListChangedNotification(),
|
||||
ResourceListChangedNotification(),
|
||||
ResourceUpdatedNotification(ResourceUpdatedNotificationParams(uri = "file:///workspace/data.json")),
|
||||
)
|
||||
|
||||
val receivedMessages = mutableListOf<JSONRPCMessage>()
|
||||
clientTransport.onMessage { msg ->
|
||||
receivedMessages.add(msg)
|
||||
}
|
||||
|
||||
notifications.forEach { notification ->
|
||||
serverTransport.send(notification.toJSON())
|
||||
}
|
||||
|
||||
assertEquals(notifications.size, receivedMessages.size)
|
||||
notifications.forEachIndexed { index, notification ->
|
||||
assertEquals(notification.toJSON(), receivedMessages[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Prompt
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.awaitility.kotlin.await
|
||||
import org.awaitility.kotlin.untilAsserted
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ServerPromptsNotificationTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
prompts = ServerCapabilities.Prompts(true),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `addPrompt should send notification`() = runTest {
|
||||
// Configure notification handler
|
||||
var promptListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<PromptListChangedNotification>(Method.Defined.NotificationsPromptsListChanged) {
|
||||
promptListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add a prompt
|
||||
val testPrompt = Prompt("test-prompt", "Test Prompt", null)
|
||||
server.addPrompt(testPrompt) {
|
||||
GetPromptResult(
|
||||
description = "Test prompt description",
|
||||
messages = listOf(),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the prompt
|
||||
val result = server.removePrompt(testPrompt.name)
|
||||
|
||||
// Verify the prompt was removed
|
||||
assertTrue(result, "Prompt should be removed successfully")
|
||||
|
||||
// Verify that the notification was sent
|
||||
await untilAsserted {
|
||||
assertTrue(promptListChangedNotificationReceived, "Notification should be sent when prompt is added")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removePrompts should remove multiple prompts and send two notifications`() = runTest {
|
||||
// Configure notification handler
|
||||
var promptListChangedNotificationReceivedCount = 0
|
||||
client.setNotificationHandler<PromptListChangedNotification>(Method.Defined.NotificationsPromptsListChanged) {
|
||||
promptListChangedNotificationReceivedCount += 1
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add prompts
|
||||
val testPrompt1 = Prompt("test-prompt-1", "Test Prompt 1", null)
|
||||
val testPrompt2 = Prompt("test-prompt-2", "Test Prompt 2", null)
|
||||
|
||||
server.addPrompt(testPrompt1) {
|
||||
GetPromptResult(
|
||||
description = "Test prompt description 1",
|
||||
messages = listOf(),
|
||||
)
|
||||
}
|
||||
server.addPrompt(testPrompt2) {
|
||||
GetPromptResult(
|
||||
description = "Test prompt description 2",
|
||||
messages = listOf(),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the prompts
|
||||
val result = server.removePrompts(listOf(testPrompt1.name, testPrompt2.name))
|
||||
|
||||
// Verify the prompts were removed
|
||||
assertEquals(2, result, "Both prompts should be removed")
|
||||
|
||||
// Verify that the notifications were sent twice
|
||||
await untilAsserted {
|
||||
assertEquals(
|
||||
4,
|
||||
promptListChangedNotificationReceivedCount,
|
||||
"Two notifications should be sent when prompts are added and two when removed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification should not be send when removed prompt does not exists`() = runTest {
|
||||
// Track notifications
|
||||
var promptListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<PromptListChangedNotification>(Method.Defined.NotificationsPromptsListChanged) {
|
||||
promptListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Try to remove a non-existent prompt
|
||||
val result = server.removePrompt("non-existent-prompt")
|
||||
|
||||
// Verify the result
|
||||
assertFalse(result, "Removing non-existent prompt should return false")
|
||||
await untilAsserted {
|
||||
assertFalse(
|
||||
promptListChangedNotificationReceived,
|
||||
"No notification should be sent when prompt doesn't exist",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,11 +17,18 @@ import kotlin.test.assertTrue
|
|||
class ServerPromptsTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
prompts = ServerCapabilities.Prompts(false),
|
||||
prompts = ServerCapabilities.Prompts(null),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `removePrompt should remove a prompt`() = runTest {
|
||||
fun `removePrompt should remove a prompt and do not send notification`() = runTest {
|
||||
// Configure notification handler
|
||||
var promptListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<PromptListChangedNotification>(Method.Defined.NotificationsPromptsListChanged) {
|
||||
promptListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add a prompt
|
||||
val testPrompt = Prompt("test-prompt", "Test Prompt", null)
|
||||
server.addPrompt(testPrompt) {
|
||||
|
|
@ -36,13 +43,30 @@ class ServerPromptsTest : AbstractServerFeaturesTest() {
|
|||
|
||||
// Verify the prompt was removed
|
||||
assertTrue(result, "Prompt should be removed successfully")
|
||||
|
||||
// Verify that the notification was not sent
|
||||
assertFalse(
|
||||
promptListChangedNotificationReceived,
|
||||
"No notification should be sent when prompts capability is not supported",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removePrompts should remove multiple prompts and send notification`() = runTest {
|
||||
fun `removePrompts should remove multiple prompts`() = runTest {
|
||||
// Configure notification handler
|
||||
var promptListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<PromptListChangedNotification>(Method.Defined.NotificationsPromptsListChanged) {
|
||||
promptListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add prompts
|
||||
val testPrompt1 = Prompt("test-prompt-1", "Test Prompt 1", null)
|
||||
val testPrompt2 = Prompt("test-prompt-2", "Test Prompt 2", null)
|
||||
client.setNotificationHandler<PromptListChangedNotification>(Method.Defined.NotificationsPromptsListChanged) {
|
||||
throw IllegalStateException("Notification should not be sent")
|
||||
}
|
||||
|
||||
server.addPrompt(testPrompt1) {
|
||||
GetPromptResult(
|
||||
description = "Test prompt description 1",
|
||||
|
|
@ -61,6 +85,12 @@ class ServerPromptsTest : AbstractServerFeaturesTest() {
|
|||
|
||||
// Verify the prompts were removed
|
||||
assertEquals(2, result, "Both prompts should be removed")
|
||||
|
||||
// Verify that the notification was not sent
|
||||
assertFalse(
|
||||
promptListChangedNotificationReceived,
|
||||
"No notification should be sent when prompts capability is not supported",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -82,6 +112,12 @@ class ServerPromptsTest : AbstractServerFeaturesTest() {
|
|||
|
||||
@Test
|
||||
fun `removePrompt should throw when prompts capability is not supported`() = runTest {
|
||||
var promptListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<PromptListChangedNotification>(Method.Defined.NotificationsPromptsListChanged) {
|
||||
promptListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Create server without prompts capability
|
||||
val serverOptions = ServerOptions(
|
||||
capabilities = ServerCapabilities(),
|
||||
|
|
@ -96,5 +132,11 @@ class ServerPromptsTest : AbstractServerFeaturesTest() {
|
|||
server.removePrompt("test-prompt")
|
||||
}
|
||||
assertEquals("Server does not support prompts capability.", exception.message)
|
||||
|
||||
// Verify that the notification was not sent
|
||||
assertFalse(
|
||||
promptListChangedNotificationReceived,
|
||||
"No notification should be sent when prompts capability is not supported",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.awaitility.kotlin.await
|
||||
import org.awaitility.kotlin.untilAsserted
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
resources = ServerCapabilities.Resources(null, true),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `removeResource should send resource update notification`() = runTest {
|
||||
val notifications = mutableListOf<ResourceUpdatedNotification>()
|
||||
client.setNotificationHandler<ResourceUpdatedNotification>(Method.Defined.NotificationsResourcesUpdated) {
|
||||
notifications.add(it)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add resources
|
||||
val testResourceUri1 = "test://resource1"
|
||||
val testResourceUri2 = "test://resource2"
|
||||
|
||||
server.addResource(
|
||||
uri = testResourceUri1,
|
||||
name = "Test Resource 1",
|
||||
description = "A test resource 1",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = "Test resource content 1",
|
||||
uri = testResourceUri1,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri1)))
|
||||
|
||||
server.addResource(
|
||||
uri = testResourceUri2,
|
||||
name = "Test Resource 2",
|
||||
description = "A test resource 2",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = "Test resource content 2",
|
||||
uri = testResourceUri2,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the resource
|
||||
val result = server.removeResource(testResourceUri1)
|
||||
|
||||
// Verify the resource was removed
|
||||
assertTrue(result, "Resource should be removed successfully")
|
||||
|
||||
// Verify that the notification was sent
|
||||
await untilAsserted {
|
||||
assertEquals(1, notifications.size, "Notification should be sent when resource 1 was deleted")
|
||||
}
|
||||
assertEquals(testResourceUri1, notifications[0].params.uri, "Notification should contain the resource 1 URI")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeResource for two resources should send two separate notifications`() = runTest {
|
||||
val notifications = mutableListOf<ResourceUpdatedNotification>()
|
||||
client.setNotificationHandler<ResourceUpdatedNotification>(Method.Defined.NotificationsResourcesUpdated) {
|
||||
notifications.add(it)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add resources
|
||||
val testResourceUri1 = "test://resource1"
|
||||
val testResourceUri2 = "test://resource2"
|
||||
|
||||
server.addResource(
|
||||
uri = testResourceUri1,
|
||||
name = "Test Resource 1",
|
||||
description = "A test resource 1",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = "Test resource content 1",
|
||||
uri = testResourceUri1,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.addResource(
|
||||
uri = testResourceUri2,
|
||||
name = "Test Resource 2",
|
||||
description = "A test resource 2",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = "Test resource content 2",
|
||||
uri = testResourceUri2,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri1)))
|
||||
client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri2)))
|
||||
|
||||
// Remove the resource
|
||||
val result1 = server.removeResource(testResourceUri1)
|
||||
val result2 = server.removeResource(testResourceUri2)
|
||||
|
||||
// Verify the resource was removed
|
||||
assertTrue(result1, "Resource 1 should be removed successfully")
|
||||
assertTrue(result2, "Resource 2 should be removed successfully")
|
||||
|
||||
println(notifications.map { it.params.uri })
|
||||
// Verify that the notification was sent
|
||||
await untilAsserted {
|
||||
assertEquals(
|
||||
2,
|
||||
notifications.size,
|
||||
"Notification should be sent when resource 1 and resource 2 was deleted",
|
||||
)
|
||||
}
|
||||
|
||||
val deletedResources = listOf(notifications[0].params.uri, notifications[1].params.uri)
|
||||
assertTrue(
|
||||
deletedResources.contains(testResourceUri1),
|
||||
"Notification should contain the removed resource 1 URI",
|
||||
)
|
||||
assertTrue(
|
||||
deletedResources.contains(testResourceUri2),
|
||||
"Notification should contain the removed resource 2 URI",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification should not be send when removed resource does not exists`() = runTest {
|
||||
// Track notifications
|
||||
var resourceListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<ResourceListChangedNotification>(
|
||||
Method.Defined.NotificationsResourcesListChanged,
|
||||
) {
|
||||
resourceListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Try to remove a non-existent resource
|
||||
val result = server.removeResource("non-existent-resource")
|
||||
|
||||
// Verify the result
|
||||
assertFalse(result, "Removing non-existent resource should return false")
|
||||
assertFalse(
|
||||
resourceListChangedNotificationReceived,
|
||||
"No notification should be sent when resource doesn't exist",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.awaitility.kotlin.await
|
||||
import org.awaitility.kotlin.untilAsserted
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ServerResourcesNotificationTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
resources = ServerCapabilities.Resources(true, null),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `addResource should send notification`() = runTest {
|
||||
// Configure notification handler
|
||||
var resourceListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<ResourceListChangedNotification>(
|
||||
Method.Defined.NotificationsResourcesListChanged,
|
||||
) {
|
||||
resourceListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add a resource
|
||||
val testResourceUri = "test://resource"
|
||||
server.addResource(
|
||||
uri = testResourceUri,
|
||||
name = "Test Resource",
|
||||
description = "A test resource",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = "Test resource content",
|
||||
uri = testResourceUri,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the resource
|
||||
val result = server.removeResource(testResourceUri)
|
||||
|
||||
// Verify the resource was removed
|
||||
assertTrue(result, "Resource should be removed successfully")
|
||||
|
||||
// Verify that the notification was sent
|
||||
await untilAsserted {
|
||||
assertTrue(resourceListChangedNotificationReceived, "Notification should be sent when resource is added")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeResources should remove multiple resources and send two notifications`() = runTest {
|
||||
// Configure notification handler
|
||||
var resourceListChangedNotificationReceivedCount = 0
|
||||
client.setNotificationHandler<ResourceListChangedNotification>(
|
||||
Method.Defined.NotificationsResourcesListChanged,
|
||||
) {
|
||||
resourceListChangedNotificationReceivedCount += 1
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add resources
|
||||
val testResourceUri1 = "test://resource1"
|
||||
val testResourceUri2 = "test://resource2"
|
||||
|
||||
server.addResource(
|
||||
uri = testResourceUri1,
|
||||
name = "Test Resource 1",
|
||||
description = "A test resource 1",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = "Test resource content 1",
|
||||
uri = testResourceUri1,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
server.addResource(
|
||||
uri = testResourceUri2,
|
||||
name = "Test Resource 2",
|
||||
description = "A test resource 2",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = "Test resource content 2",
|
||||
uri = testResourceUri2,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the resources
|
||||
val result = server.removeResources(listOf(testResourceUri1, testResourceUri2))
|
||||
|
||||
// Verify the resources were removed
|
||||
assertEquals(2, result, "Both resources should be removed")
|
||||
|
||||
// Verify that the notifications were sent twice
|
||||
await untilAsserted {
|
||||
assertEquals(
|
||||
4,
|
||||
resourceListChangedNotificationReceivedCount,
|
||||
"Two notifications should be sent when resources are added and two when removed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification should not be send when removed resource does not exists`() = runTest {
|
||||
// Track notifications
|
||||
var resourceListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<ResourceListChangedNotification>(
|
||||
Method.Defined.NotificationsResourcesListChanged,
|
||||
) {
|
||||
resourceListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Try to remove a non-existent resource
|
||||
val result = server.removeResource("non-existent-resource")
|
||||
|
||||
// Verify the result
|
||||
assertFalse(result, "Removing non-existent resource should return false")
|
||||
assertFalse(
|
||||
resourceListChangedNotificationReceived,
|
||||
"No notification should be sent when resource doesn't exist",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ class ServerResourcesTest : AbstractServerFeaturesTest() {
|
|||
)
|
||||
|
||||
@Test
|
||||
fun `removeResource should remove a resource and send notification`() = runTest {
|
||||
fun `removeResource should remove a resource`() = runTest {
|
||||
// Add a resource
|
||||
val testResourceUri = "test://resource"
|
||||
server.addResource(
|
||||
|
|
@ -49,7 +49,7 @@ class ServerResourcesTest : AbstractServerFeaturesTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `removeResources should remove multiple resources and send notification`() = runTest {
|
||||
fun `removeResources should remove multiple resources`() = runTest {
|
||||
// Add resources
|
||||
val testResourceUri1 = "test://resource1"
|
||||
val testResourceUri2 = "test://resource2"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.awaitility.kotlin.await
|
||||
import org.awaitility.kotlin.untilAsserted
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ServerToolsNotificationTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(true),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `addTool should send notification`() = runTest {
|
||||
// Configure notification handler
|
||||
var toolListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<ToolListChangedNotification>(Method.Defined.NotificationsToolsListChanged) {
|
||||
toolListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add a tool
|
||||
server.addTool("test-tool", "Test Tool", ToolSchema()) {
|
||||
CallToolResult(listOf(TextContent("Test result")))
|
||||
}
|
||||
|
||||
// Remove the tool
|
||||
val result = server.removeTool("test-tool")
|
||||
|
||||
// Verify the tool was removed
|
||||
assertTrue(result, "Tool should be removed successfully")
|
||||
|
||||
// Verify that the notification was sent
|
||||
await untilAsserted {
|
||||
assertTrue(toolListChangedNotificationReceived, "Notification should be sent when tool is added")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeTools should remove multiple tools and send two notifications`() = runTest {
|
||||
// Configure notification handler
|
||||
var toolListChangedNotificationReceivedCount = 0
|
||||
client.setNotificationHandler<ToolListChangedNotification>(Method.Defined.NotificationsToolsListChanged) {
|
||||
toolListChangedNotificationReceivedCount += 1
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add tools
|
||||
server.addTool("test-tool-1", "Test Tool 1") {
|
||||
CallToolResult(listOf(TextContent("Test result 1")))
|
||||
}
|
||||
server.addTool("test-tool-2", "Test Tool 2") {
|
||||
CallToolResult(listOf(TextContent("Test result 2")))
|
||||
}
|
||||
|
||||
// Remove the tools
|
||||
val result = server.removeTools(listOf("test-tool-1", "test-tool-2"))
|
||||
// Verify the tools were removed
|
||||
assertEquals(2, result, "Both tools should be removed")
|
||||
|
||||
// Verify that the notifications were sent twice
|
||||
await untilAsserted {
|
||||
assertEquals(
|
||||
4,
|
||||
toolListChangedNotificationReceivedCount,
|
||||
"Two notifications should be sent when tools are added and two when removed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification should not be send when removed tool does not exists`() = runTest {
|
||||
// Track notifications
|
||||
var toolListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<ToolListChangedNotification>(Method.Defined.NotificationsToolsListChanged) {
|
||||
toolListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Try to remove a non-existent tool
|
||||
val result = server.removeTool("non-existent-tool")
|
||||
// Close the server to stop processing further events and flush notifications
|
||||
server.close()
|
||||
|
||||
// Verify the result
|
||||
assertFalse(result, "Removing non-existent tool should return false")
|
||||
assertFalse(toolListChangedNotificationReceived, "No notification should be sent when tool doesn't exist")
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,14 @@ class ServerToolsTest : AbstractServerFeaturesTest() {
|
|||
)
|
||||
|
||||
@Test
|
||||
fun `removeTool should remove a tool`() = runTest {
|
||||
fun `removeTool should remove a tool and do not send notification`() = runTest {
|
||||
// Configure notification handler
|
||||
var toolListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<ToolListChangedNotification>(Method.Defined.NotificationsToolsListChanged) {
|
||||
toolListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add a tool
|
||||
server.addTool("test-tool", "Test Tool", ToolSchema()) {
|
||||
CallToolResult(listOf(TextContent("Test result")))
|
||||
|
|
@ -33,6 +40,45 @@ class ServerToolsTest : AbstractServerFeaturesTest() {
|
|||
|
||||
// Verify the tool was removed
|
||||
assertTrue(result, "Tool should be removed successfully")
|
||||
|
||||
// Verify that the notification was not sent
|
||||
assertFalse(
|
||||
toolListChangedNotificationReceived,
|
||||
"No notification should be sent when tools capability is not supported",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeTools should remove multiple tools`() = runTest {
|
||||
// Configure notification handler
|
||||
var toolListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<ToolListChangedNotification>(Method.Defined.NotificationsToolsListChanged) {
|
||||
toolListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Add tools
|
||||
server.addTool("test-tool-1", "Test Tool 1") {
|
||||
CallToolResult(listOf(TextContent("Test result 1")))
|
||||
}
|
||||
server.addTool("test-tool-2", "Test Tool 2") {
|
||||
CallToolResult(listOf(TextContent("Test result 2")))
|
||||
}
|
||||
client.setNotificationHandler<ToolListChangedNotification>(Method.Defined.NotificationsToolsListChanged) {
|
||||
throw IllegalStateException("Notification should not be sent")
|
||||
}
|
||||
|
||||
// Remove the tools
|
||||
val result = server.removeTools(listOf("test-tool-1", "test-tool-2"))
|
||||
|
||||
// Verify the tools were removed
|
||||
assertEquals(2, result, "Both tools should be removed")
|
||||
|
||||
// Verify that the notification was not sent
|
||||
assertFalse(
|
||||
toolListChangedNotificationReceived,
|
||||
"No notification should be sent when tools capability is not supported",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -54,6 +100,12 @@ class ServerToolsTest : AbstractServerFeaturesTest() {
|
|||
|
||||
@Test
|
||||
fun `removeTool should throw when tools capability is not supported`() = runTest {
|
||||
var toolListChangedNotificationReceived = false
|
||||
client.setNotificationHandler<ToolListChangedNotification>(Method.Defined.NotificationsToolsListChanged) {
|
||||
toolListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
// Create server without tools capability
|
||||
val serverOptions = ServerOptions(
|
||||
capabilities = ServerCapabilities(),
|
||||
|
|
@ -68,22 +120,11 @@ class ServerToolsTest : AbstractServerFeaturesTest() {
|
|||
server.removeTool("test-tool")
|
||||
}
|
||||
assertEquals("Server does not support tools capability.", exception.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeTools should remove multiple tools`() = runTest {
|
||||
// Add tools
|
||||
server.addTool("test-tool-1", "Test Tool 1") {
|
||||
CallToolResult(listOf(TextContent("Test result 1")))
|
||||
}
|
||||
server.addTool("test-tool-2", "Test Tool 2") {
|
||||
CallToolResult(listOf(TextContent("Test result 2")))
|
||||
}
|
||||
|
||||
// Remove the tools
|
||||
val result = server.removeTools(listOf("test-tool-1", "test-tool-2"))
|
||||
|
||||
// Verify the tools were removed
|
||||
assertEquals(2, result, "Both tools should be removed")
|
||||
// Verify that the notification was not sent
|
||||
assertFalse(
|
||||
toolListChangedNotificationReceived,
|
||||
"No notification should be sent when tools capability is not supported",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue